cantus-agent 0.4.2__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 (70) hide show
  1. cantus/__init__.py +131 -0
  2. cantus/adapters/__init__.py +135 -0
  3. cantus/adapters/_remote_skill.py +72 -0
  4. cantus/adapters/anthropic_memory.py +107 -0
  5. cantus/adapters/dspy.py +149 -0
  6. cantus/adapters/huggingface.py +117 -0
  7. cantus/adapters/langchain.py +144 -0
  8. cantus/adapters/mcp.py +156 -0
  9. cantus/adapters/mcp_client.py +164 -0
  10. cantus/adapters/mcp_server.py +141 -0
  11. cantus/adapters/openhands.py +50 -0
  12. cantus/config.py +73 -0
  13. cantus/core/__init__.py +0 -0
  14. cantus/core/action.py +38 -0
  15. cantus/core/agent.py +420 -0
  16. cantus/core/event_stream.py +48 -0
  17. cantus/core/event_stream_persistence.py +79 -0
  18. cantus/core/observation.py +74 -0
  19. cantus/core/registry.py +83 -0
  20. cantus/core/result.py +28 -0
  21. cantus/env/__init__.py +26 -0
  22. cantus/env/cloud_only.py +23 -0
  23. cantus/env/colab.py +76 -0
  24. cantus/env/local.py +62 -0
  25. cantus/grammar/__init__.py +0 -0
  26. cantus/grammar/tool_call.py +136 -0
  27. cantus/hooks/__init__.py +37 -0
  28. cantus/identity/__init__.py +15 -0
  29. cantus/identity/soul.py +157 -0
  30. cantus/inspect.py +41 -0
  31. cantus/model/__init__.py +0 -0
  32. cantus/model/bridge.py +46 -0
  33. cantus/model/chat.py +96 -0
  34. cantus/model/chat_template.py +62 -0
  35. cantus/model/factory.py +93 -0
  36. cantus/model/loader.py +151 -0
  37. cantus/model/providers/__init__.py +6 -0
  38. cantus/model/providers/_common.py +26 -0
  39. cantus/model/providers/_translate.py +339 -0
  40. cantus/model/providers/anthropic.py +88 -0
  41. cantus/model/providers/google.py +91 -0
  42. cantus/model/providers/groq.py +87 -0
  43. cantus/model/providers/nvidia.py +39 -0
  44. cantus/model/providers/openai.py +95 -0
  45. cantus/protocols/__init__.py +0 -0
  46. cantus/protocols/_common.py +72 -0
  47. cantus/protocols/analyzer.py +82 -0
  48. cantus/protocols/debug.py +76 -0
  49. cantus/protocols/memory.py +191 -0
  50. cantus/protocols/memory_auto.py +154 -0
  51. cantus/protocols/memory_markdown.py +211 -0
  52. cantus/protocols/skill.py +138 -0
  53. cantus/protocols/validator.py +114 -0
  54. cantus/py.typed +0 -0
  55. cantus/serve/__init__.py +26 -0
  56. cantus/serve/app.py +158 -0
  57. cantus/serve/channel.py +64 -0
  58. cantus/serve/dashboard.py +144 -0
  59. cantus/serve/security.py +144 -0
  60. cantus/workflows/__init__.py +24 -0
  61. cantus/workflows/evaluator_optimizer.py +49 -0
  62. cantus/workflows/orchestrator_worker.py +41 -0
  63. cantus/workflows/parallel.py +24 -0
  64. cantus/workflows/prompt_chain.py +26 -0
  65. cantus/workflows/router.py +36 -0
  66. cantus_agent-0.4.2.dist-info/METADATA +281 -0
  67. cantus_agent-0.4.2.dist-info/RECORD +70 -0
  68. cantus_agent-0.4.2.dist-info/WHEEL +5 -0
  69. cantus_agent-0.4.2.dist-info/licenses/LICENSE +203 -0
  70. cantus_agent-0.4.2.dist-info/top_level.txt +1 -0
cantus/__init__.py ADDED
@@ -0,0 +1,131 @@
1
+ """cantus — Polyphonic LLM agent framework with a dual-tier teaching API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from cantus.model.loader import ModelHandle
9
+
10
+ __version__ = "0.4.2"
11
+
12
+ from cantus.core.action import (
13
+ Action,
14
+ CallSkillAction,
15
+ FinalAnswerAction,
16
+ )
17
+ from cantus.core.agent import Agent, AgentState
18
+ from cantus.core.event_stream import EventStream
19
+ from cantus.core.event_stream_persistence import JsonLinesPersistence
20
+ from cantus.core.observation import (
21
+ MaxIterationsObservation,
22
+ Observation,
23
+ SkillObservation,
24
+ ToolErrorObservation,
25
+ ValidationErrorObservation,
26
+ )
27
+ from cantus.core.registry import Registry, get_registry
28
+ from cantus.core.result import Result
29
+ from cantus.env import CloudOnlyEnvironment, ColabEnvironment, LocalEnvironment
30
+ from cantus.identity import Soul, SoulParseError
31
+ from cantus.inspect import Inspector
32
+ from cantus.model.bridge import ChatModelAsHandle
33
+ from cantus.model.chat import ChatModel, ChatResponse, Message, ToolCall
34
+ from cantus.model.factory import load_chat_model
35
+ from cantus.protocols.debug import debug
36
+ from cantus.protocols.memory import (
37
+ AutoMemory,
38
+ BM25Memory,
39
+ EmbeddingMemory,
40
+ MarkdownMemory,
41
+ Memory,
42
+ ShortTermMemory,
43
+ )
44
+ from cantus.protocols.skill import Skill, register_skill, skill
45
+
46
+ __all__ = [
47
+ # Decorator entries (v0.3.0: Skill + debug only at top level;
48
+ # analyzer/validator are now imported from cantus.hooks)
49
+ "skill",
50
+ "debug",
51
+ # Function-pass entries
52
+ "register_skill",
53
+ # Class-first base classes (top level: Skill + Memory only;
54
+ # Analyzer/Validator are imported from cantus.hooks)
55
+ "Skill",
56
+ "Memory",
57
+ # Memory implementations (v0.3.1: MarkdownMemory + AutoMemory join)
58
+ "ShortTermMemory",
59
+ "BM25Memory",
60
+ "EmbeddingMemory",
61
+ "MarkdownMemory",
62
+ "AutoMemory",
63
+ # Identity (v0.3.1)
64
+ "Soul",
65
+ "SoulParseError",
66
+ # EventStream persistence plug (v0.3.1)
67
+ "JsonLinesPersistence",
68
+ # Runtime
69
+ "Action",
70
+ "CallSkillAction",
71
+ "FinalAnswerAction",
72
+ "Observation",
73
+ "SkillObservation",
74
+ "ToolErrorObservation",
75
+ "ValidationErrorObservation",
76
+ "MaxIterationsObservation",
77
+ "EventStream",
78
+ "Agent",
79
+ "AgentState",
80
+ "Inspector",
81
+ # Registry
82
+ "Registry",
83
+ "get_registry",
84
+ # Result type
85
+ "Result",
86
+ # Tier 2 ChatModel
87
+ "ChatModel",
88
+ "Message",
89
+ "ToolCall",
90
+ "ChatResponse",
91
+ "ChatModelAsHandle",
92
+ "load_chat_model",
93
+ # Environment profiles
94
+ "ColabEnvironment",
95
+ "LocalEnvironment",
96
+ "CloudOnlyEnvironment",
97
+ # Serve layer (v0.4.0, lazy-loaded; require `pip install cantus[serve]`)
98
+ "serve",
99
+ "config",
100
+ ]
101
+
102
+
103
+ def __getattr__(name: str) -> Any:
104
+ """PEP 562 lazy loader for the v0.4.0 serve layer.
105
+
106
+ `cantus.serve` and `cantus.config` are exposed at the top level but
107
+ imported on first attribute access so that a base ``import cantus``
108
+ does not require ``fastapi`` / ``uvicorn`` / ``pydantic-settings``.
109
+ Missing extras still surface as ``ImportError`` with the literal
110
+ substring ``"pip install cantus[serve]"`` from the gate inside the
111
+ target module.
112
+ """
113
+ if name in {"serve", "config"}:
114
+ import importlib
115
+
116
+ module = importlib.import_module(f"cantus.{name}")
117
+ globals()[name] = module
118
+ return module
119
+ raise AttributeError(f"module 'cantus' has no attribute {name!r}")
120
+
121
+
122
+ def mount_drive_and_load(*args: object, **kwargs: object) -> ModelHandle:
123
+ """Mount Google Drive and load Gemma 4 weights — see model.loader."""
124
+ from cantus.model.loader import mount_drive_and_load as _impl
125
+
126
+ return _impl(*args, **kwargs) # type: ignore[arg-type]
127
+
128
+
129
+ def load_gemma(*args: object, **kwargs: object) -> ModelHandle:
130
+ """Alias of mount_drive_and_load for direct hub loading scenarios."""
131
+ return mount_drive_and_load(*args, **kwargs)
@@ -0,0 +1,135 @@
1
+ """cantus.adapters — bridges to MCP, Anthropic Memory, and four
2
+ cross-framework targets (LangChain / DSPy / HuggingFace / OpenHands).
3
+
4
+ The adapter layer is a pure conversion utility: it does NOT alter Skill
5
+ or Memory runtime behaviour and does NOT register a new protocol kind.
6
+ The package exposes ten top-level callables (3 from v0.3.2 + 6 from
7
+ v0.3.3 + 1 from v0.3.4):
8
+
9
+ v0.3.2 (MCP + Anthropic Memory):
10
+ - ``export_as_mcp_server(skills, *, name, version)`` — wrap a list of
11
+ cantus Skills as an MCP server (requires ``cantus[mcp]``).
12
+ - ``import_mcp_server(*, transport, command_or_url)`` — connect to a
13
+ remote MCP server and return a list of cantus Skills.
14
+ - ``expose_as_anthropic_memory_tool(memory)`` — JSON-serialisable dict
15
+ matching the Anthropic Memory tool spec (no SDK dependency).
16
+
17
+ v0.3.3 (cross-framework batch2):
18
+ - ``expose_as_langchain_tool(skill)`` / ``import_langchain_tool(tool)`` —
19
+ cantus Skill <-> ``langchain_core.tools.BaseTool``
20
+ (requires ``cantus[langchain]``).
21
+ - ``expose_as_dspy_tool(skill)`` / ``import_dspy_tool(tool)`` —
22
+ cantus Skill <-> ``dspy.Tool``
23
+ (requires ``cantus[dspy]``).
24
+ - ``expose_as_hf_tool(skill)`` — cantus Skill -> ``transformers.Tool``
25
+ (requires ``cantus[huggingface]``).
26
+ - ``expose_as_openhands_action(skill)`` — cantus Skill ->
27
+ ``openhands.events.Action`` (requires ``cantus[openhands]``;
28
+ export-only — the import direction is permanently not applicable
29
+ because ``Action`` is a declarative event record dispatched by the
30
+ OpenHands host runtime, with no ``__call__`` for ``Skill.run`` to
31
+ delegate to).
32
+
33
+ v0.3.4 (cross-framework batch3a — HF bidirectional close-out):
34
+ - ``import_hf_tool(tool)`` — cantus Skill <- ``transformers.Tool``
35
+ (requires ``cantus[huggingface]``). Completes the HuggingFace
36
+ bidirectional matrix; the OpenHands import direction is intentionally
37
+ omitted, see ``expose_as_openhands_action`` above.
38
+
39
+ Every batch2/batch3 entry lazy-imports its SDK only when called; the core
40
+ ``pip install cantus`` install still resolves ``import cantus.adapters``
41
+ without pulling any framework SDK.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from typing import Any
47
+
48
+
49
+ def export_as_mcp_server(*args: Any, **kwargs: Any) -> Any:
50
+ """Stub. Implemented in cantus.adapters.mcp_server."""
51
+ from cantus.adapters.mcp_server import export_as_mcp_server as _impl
52
+
53
+ return _impl(*args, **kwargs)
54
+
55
+
56
+ def import_mcp_server(*args: Any, **kwargs: Any) -> Any:
57
+ """Stub. Implemented in cantus.adapters.mcp_client."""
58
+ from cantus.adapters.mcp_client import import_mcp_server as _impl
59
+
60
+ return _impl(*args, **kwargs)
61
+
62
+
63
+ def expose_as_anthropic_memory_tool(*args: Any, **kwargs: Any) -> Any:
64
+ """Stub. Implemented in cantus.adapters.anthropic_memory."""
65
+ from cantus.adapters.anthropic_memory import (
66
+ expose_as_anthropic_memory_tool as _impl,
67
+ )
68
+
69
+ return _impl(*args, **kwargs)
70
+
71
+
72
+ def expose_as_langchain_tool(*args: Any, **kwargs: Any) -> Any:
73
+ """Stub. Implemented in cantus.adapters.langchain (requires cantus[langchain])."""
74
+ from cantus.adapters.langchain import expose_as_langchain_tool as _impl
75
+
76
+ return _impl(*args, **kwargs)
77
+
78
+
79
+ def import_langchain_tool(*args: Any, **kwargs: Any) -> Any:
80
+ """Stub. Implemented in cantus.adapters.langchain (requires cantus[langchain])."""
81
+ from cantus.adapters.langchain import import_langchain_tool as _impl
82
+
83
+ return _impl(*args, **kwargs)
84
+
85
+
86
+ def expose_as_dspy_tool(*args: Any, **kwargs: Any) -> Any:
87
+ """Stub. Implemented in cantus.adapters.dspy (requires cantus[dspy])."""
88
+ from cantus.adapters.dspy import expose_as_dspy_tool as _impl
89
+
90
+ return _impl(*args, **kwargs)
91
+
92
+
93
+ def import_dspy_tool(*args: Any, **kwargs: Any) -> Any:
94
+ """Stub. Implemented in cantus.adapters.dspy (requires cantus[dspy])."""
95
+ from cantus.adapters.dspy import import_dspy_tool as _impl
96
+
97
+ return _impl(*args, **kwargs)
98
+
99
+
100
+ def expose_as_hf_tool(*args: Any, **kwargs: Any) -> Any:
101
+ """Stub. Implemented in cantus.adapters.huggingface (requires cantus[huggingface])."""
102
+ from cantus.adapters.huggingface import expose_as_hf_tool as _impl
103
+
104
+ return _impl(*args, **kwargs)
105
+
106
+
107
+ def import_hf_tool(*args: Any, **kwargs: Any) -> Any:
108
+ """Stub. Implemented in cantus.adapters.huggingface (requires cantus[huggingface])."""
109
+ from cantus.adapters.huggingface import import_hf_tool as _impl
110
+
111
+ return _impl(*args, **kwargs)
112
+
113
+
114
+ def expose_as_openhands_action(*args: Any, **kwargs: Any) -> Any:
115
+ """Stub. Implemented in cantus.adapters.openhands (requires cantus[openhands])."""
116
+ from cantus.adapters.openhands import expose_as_openhands_action as _impl
117
+
118
+ return _impl(*args, **kwargs)
119
+
120
+
121
+ __all__ = [
122
+ # v0.3.2 — MCP + Anthropic Memory
123
+ "export_as_mcp_server",
124
+ "import_mcp_server",
125
+ "expose_as_anthropic_memory_tool",
126
+ # v0.3.3 — cross-framework batch2
127
+ "expose_as_langchain_tool",
128
+ "import_langchain_tool",
129
+ "expose_as_dspy_tool",
130
+ "import_dspy_tool",
131
+ "expose_as_hf_tool",
132
+ "expose_as_openhands_action",
133
+ # v0.3.4 — cross-framework batch3a (HF bidirectional close-out)
134
+ "import_hf_tool",
135
+ ]
@@ -0,0 +1,72 @@
1
+ """Shared base class for all `cantus.adapters.import_*` adapters.
2
+
3
+ `_RemoteSkillBase` lifts the three load-bearing patterns from v0.3.2
4
+ `cantus.adapters.mcp_client._RemoteSkill` into a private, framework-internal
5
+ base so that LangChain / DSPy / future cross-framework import adapters
6
+ inherit the same v0.3.0 `Skill.spec_for_llm()` shape contract instead of
7
+ duplicating it per adapter:
8
+
9
+ 1. ``__init__`` bypasses :class:`Skill`'s signature-introspection path —
10
+ the remote framework's schema is authoritative.
11
+ 2. ``spec_for_llm()`` returns the canonical three-key dict directly from
12
+ the remote schema dict; ``is_remote = True`` is NOT leaked into it.
13
+ 3. ``validate_args()`` trusts the remote framework's schema and only
14
+ enforces dict shape at the protocol layer.
15
+
16
+ This module is private (leading underscore in module name) and SHALL NOT
17
+ be re-exported from ``cantus.adapters.__init__``; the adapter-layer-batch2
18
+ spec explicitly forbids exposing an ``Adapter`` ABC as public surface.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from cantus.protocols.skill import Skill
26
+
27
+
28
+ class _RemoteSkillBase(Skill):
29
+ """Internal base for cantus Skills that proxy a remote framework tool."""
30
+
31
+ is_remote = True
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ name: str,
37
+ description: str,
38
+ args_schema_dict: dict[str, Any],
39
+ ) -> None:
40
+ # Intentionally bypass Skill.__init__: remote frameworks (MCP,
41
+ # LangChain, DSPy, OpenHands) supply their own authoritative
42
+ # schema, so signature introspection of `run` is both useless
43
+ # (the body just dispatches kwargs) and wrong (it would invent
44
+ # an empty Pydantic model with no fields).
45
+ self.name = name
46
+ self.description = description
47
+ self._args_schema_dict = args_schema_dict
48
+ self._pre_hook = None
49
+ self._post_hook = None
50
+
51
+ def spec_for_llm(self) -> dict[str, Any]:
52
+ return {
53
+ "name": self.name,
54
+ "description": self.description,
55
+ "args_schema": self._args_schema_dict,
56
+ }
57
+
58
+ def validate_args(self, args: dict[str, Any]) -> dict[str, Any]:
59
+ if not isinstance(args, dict):
60
+ raise TypeError(
61
+ f"remote adapter tool args must be a dict, "
62
+ f"got {type(args).__name__}"
63
+ )
64
+ return dict(args)
65
+
66
+ def run(self, **kwargs: Any) -> Any:
67
+ raise NotImplementedError(
68
+ "subclass must implement run() with framework-specific dispatch"
69
+ )
70
+
71
+
72
+ __all__ = ["_RemoteSkillBase"]
@@ -0,0 +1,107 @@
1
+ """expose_as_anthropic_memory_tool — cantus Memory → Anthropic Memory tool dict.
2
+
3
+ Returns a pure Python `dict` (no callables, no Memory instance references)
4
+ matching the Anthropic Memory tool spec: a 4-action surface (`view`,
5
+ `create`, `str_replace`, `delete`) over a Skill-shaped command schema. The
6
+ returned dict round-trips through `json.dumps` so host code can feed it
7
+ straight into `client.messages.create(tools=[...])`.
8
+
9
+ The adapter does NOT wire the LLM's tool_use callbacks back into the
10
+ Memory backend — that's host code's responsibility. The returned dict is
11
+ opaque to the Memory instance; the host pattern is:
12
+
13
+ 1. send `tool_dict` to Anthropic via `tools=[...]`
14
+ 2. when Claude emits `tool_use` with `name="memory"`, dispatch the
15
+ `command` argument back into the appropriate `memory.recall` /
16
+ `memory.remember` call
17
+
18
+ Foot-gun warning carry-over (v0.3.1 audit Trap-10): under this tool_use
19
+ loop, the LLM has full CRUD access to the memory backend with no built-in
20
+ filter. For production, wrap individual commands with validation in the
21
+ host dispatch layer.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ from cantus.protocols.memory import AutoMemory, Memory
29
+
30
+ _VIEW_SCHEMA: dict[str, Any] = {
31
+ "type": "object",
32
+ "properties": {
33
+ "query": {
34
+ "type": "string",
35
+ "description": "Substring to match against stored turn content (case-insensitive).",
36
+ }
37
+ },
38
+ "required": ["query"],
39
+ }
40
+
41
+ _CREATE_SCHEMA: dict[str, Any] = {
42
+ "type": "object",
43
+ "properties": {
44
+ "user": {"type": "string", "description": "User-side content of the turn."},
45
+ "assistant": {"type": "string", "description": "Assistant-side content of the turn."},
46
+ },
47
+ "required": ["user", "assistant"],
48
+ }
49
+
50
+ _STR_REPLACE_SCHEMA: dict[str, Any] = {
51
+ "type": "object",
52
+ "properties": {
53
+ "query": {"type": "string", "description": "Substring filter selecting turns to modify."},
54
+ "old": {"type": "string", "description": "Old substring to replace within matched turns."},
55
+ "new": {"type": "string", "description": "Replacement substring."},
56
+ },
57
+ "required": ["query", "old", "new"],
58
+ }
59
+
60
+ _DELETE_SCHEMA: dict[str, Any] = {
61
+ "type": "object",
62
+ "properties": {
63
+ "query": {"type": "string", "description": "Substring filter selecting turns to delete."},
64
+ },
65
+ "required": ["query"],
66
+ }
67
+
68
+
69
+ def expose_as_anthropic_memory_tool(memory: Memory | AutoMemory) -> dict[str, Any]:
70
+ """Return a JSON-serialisable Anthropic Memory tool dict for the given Memory."""
71
+ if not isinstance(memory, (Memory, AutoMemory)):
72
+ raise TypeError(
73
+ f"expose_as_anthropic_memory_tool expects Memory or AutoMemory, "
74
+ f"got {type(memory).__name__}"
75
+ )
76
+
77
+ backend_name = type(memory).__name__
78
+ description = f"Cantus {backend_name} exposed as Anthropic Memory tool"
79
+
80
+ commands: dict[str, dict[str, Any]] = {
81
+ "view": {
82
+ "description": "Recall stored turns whose user or assistant field matches the query.",
83
+ "args_schema": _VIEW_SCHEMA,
84
+ },
85
+ "create": {
86
+ "description": "Remember a new (user, assistant) turn.",
87
+ "args_schema": _CREATE_SCHEMA,
88
+ },
89
+ "str_replace": {
90
+ "description": "Find matching turns and replace `old` with `new` in their fields.",
91
+ "args_schema": _STR_REPLACE_SCHEMA,
92
+ },
93
+ "delete": {
94
+ "description": "Mark stored turns matching the query as deleted (backend-dependent).",
95
+ "args_schema": _DELETE_SCHEMA,
96
+ },
97
+ }
98
+
99
+ return {
100
+ "type": "memory",
101
+ "name": "memory",
102
+ "description": description,
103
+ "commands": commands,
104
+ }
105
+
106
+
107
+ __all__ = ["expose_as_anthropic_memory_tool"]
@@ -0,0 +1,149 @@
1
+ """DSPy adapter — bidirectional cantus <-> dspy.Tool.
2
+
3
+ Schema conversion follows the v0.3.3 design.md "LangChain / DSPy import_*
4
+ schema 轉換策略" decision: types are mapped via a fixed JSON Schema
5
+ ``{str, int, float, bool}`` <-> Python type table, with other types
6
+ falling back to ``str`` / ``"string"``. The framework does NOT attempt
7
+ universal coverage of complex generics (``list[str]``, ``Optional[X]``,
8
+ unions); that complexity is intentionally pushed to a future revision
9
+ when real student demand surfaces.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ try: # SDK gate — fail loud the moment this module is imported.
17
+ import dspy
18
+ except ImportError as exc:
19
+ raise ImportError(
20
+ "cantus.adapters.dspy requires the dspy-ai SDK. "
21
+ "Run: pip install cantus[dspy]"
22
+ ) from exc
23
+
24
+ from cantus.adapters._remote_skill import _RemoteSkillBase
25
+ from cantus.protocols.skill import Skill
26
+
27
+ _JSON_TYPE_TO_PY: dict[str, type] = {
28
+ "string": str,
29
+ "integer": int,
30
+ "number": float,
31
+ "boolean": bool,
32
+ }
33
+
34
+ _PY_TYPE_TO_JSON: dict[type, str] = {
35
+ str: "string",
36
+ int: "integer",
37
+ float: "number",
38
+ bool: "boolean",
39
+ }
40
+
41
+
42
+ def _py_type_to_json_type(py_type: Any) -> str:
43
+ return _PY_TYPE_TO_JSON.get(py_type, "string")
44
+
45
+
46
+ class _CantusDspyInputField:
47
+ """Shape that mirrors a dspy input-field descriptor for exposed Skills."""
48
+
49
+ def __init__(self, py_type: type, *, optional: bool = False) -> None:
50
+ self.py_type = py_type
51
+ self.optional = optional
52
+
53
+
54
+ class _CantusDspySignature:
55
+ """Minimal signature shape with an ``input_fields`` dict."""
56
+
57
+ def __init__(self, input_fields: dict[str, _CantusDspyInputField]) -> None:
58
+ self.input_fields = input_fields
59
+
60
+
61
+ def expose_as_dspy_tool(skill: Skill) -> "dspy.Tool":
62
+ """Wrap a cantus Skill as a dspy.Tool instance."""
63
+ if not isinstance(skill, Skill):
64
+ raise TypeError(
65
+ f"expose_as_dspy_tool expects Skill, got {type(skill).__name__}"
66
+ )
67
+
68
+ spec = skill.spec_for_llm()
69
+ properties = (spec["args_schema"].get("properties") or {})
70
+ required = set(spec["args_schema"].get("required") or [])
71
+
72
+ input_fields: dict[str, Any] = {}
73
+ for prop_name, prop_schema in properties.items():
74
+ json_type = prop_schema.get("type", "string")
75
+ py_type = _JSON_TYPE_TO_PY.get(json_type, str)
76
+ optional = prop_name not in required
77
+ input_fields[prop_name] = _CantusDspyInputField(py_type, optional=optional)
78
+
79
+ signature = _CantusDspySignature(input_fields)
80
+
81
+ def _impl(**kwargs: Any) -> Any:
82
+ return skill(**kwargs)
83
+
84
+ return dspy.Tool(
85
+ name=spec["name"],
86
+ desc=spec["description"],
87
+ signature=signature,
88
+ impl=_impl,
89
+ )
90
+
91
+
92
+ class _DspyRemoteSkill(_RemoteSkillBase):
93
+ """cantus Skill that proxies a dspy.Tool."""
94
+
95
+ def __init__(self, *, tool: "dspy.Tool") -> None:
96
+ signature = getattr(tool, "signature", None)
97
+ try:
98
+ input_fields = signature.input_fields # type: ignore[union-attr]
99
+ if not isinstance(input_fields, dict):
100
+ raise TypeError(
101
+ f"input_fields must be a dict, got {type(input_fields).__name__}"
102
+ )
103
+ except Exception as exc:
104
+ raise RuntimeError(
105
+ f"dspy_handshake_failed: cannot read signature.input_fields: "
106
+ f"{type(exc).__name__}: {exc}"
107
+ ) from exc
108
+
109
+ properties: dict[str, dict[str, Any]] = {}
110
+ required: list[str] = []
111
+ for field_name, field in input_fields.items():
112
+ py_type = getattr(field, "py_type", str)
113
+ properties[field_name] = {"type": _py_type_to_json_type(py_type)}
114
+ if not getattr(field, "optional", False):
115
+ required.append(field_name)
116
+
117
+ args_schema_dict: dict[str, Any] = {
118
+ "type": "object",
119
+ "properties": properties,
120
+ "required": required,
121
+ }
122
+
123
+ super().__init__(
124
+ name=tool.name,
125
+ description=getattr(tool, "desc", "") or "",
126
+ args_schema_dict=args_schema_dict,
127
+ )
128
+ self._tool = tool
129
+
130
+ def run(self, **kwargs: Any) -> Any:
131
+ try:
132
+ return self._tool(**kwargs)
133
+ except Exception as exc: # noqa: BLE001
134
+ raise RuntimeError(
135
+ f"dspy_remote_error: tool {self.name!r} failed: "
136
+ f"{type(exc).__name__}: {exc}"
137
+ ) from exc
138
+
139
+
140
+ def import_dspy_tool(tool: "dspy.Tool") -> Skill:
141
+ """Wrap a dspy.Tool as a cantus Skill instance."""
142
+ if not isinstance(tool, dspy.Tool):
143
+ raise TypeError(
144
+ f"import_dspy_tool expects dspy.Tool, got {type(tool).__name__}"
145
+ )
146
+ return _DspyRemoteSkill(tool=tool)
147
+
148
+
149
+ __all__ = ["expose_as_dspy_tool", "import_dspy_tool"]