sectum-ai-adapters 0.1.1__tar.gz

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 (44) hide show
  1. sectum_ai_adapters-0.1.1/.gitignore +45 -0
  2. sectum_ai_adapters-0.1.1/PKG-INFO +96 -0
  3. sectum_ai_adapters-0.1.1/README.md +31 -0
  4. sectum_ai_adapters-0.1.1/pyproject.toml +63 -0
  5. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/__init__.py +92 -0
  6. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/__init__.py +10 -0
  7. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/_anthropic_tooluse_live.py +180 -0
  8. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/_openai_assistants_live.py +149 -0
  9. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/anthropic_tooluse.py +162 -0
  10. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/autogen.py +292 -0
  11. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/crewai.py +233 -0
  12. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/http.py +64 -0
  13. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/langgraph.py +179 -0
  14. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/agent/openai_assistants.py +173 -0
  15. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/base.py +488 -0
  16. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/cache/__init__.py +6 -0
  17. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/cache/redis.py +90 -0
  18. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/fakes.py +909 -0
  19. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/mcp/__init__.py +8 -0
  20. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/mcp/client.py +79 -0
  21. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/mcp/http.py +90 -0
  22. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/model/__init__.py +5 -0
  23. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/model/_huggingface_live.py +187 -0
  24. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/model/huggingface.py +256 -0
  25. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/__init__.py +7 -0
  26. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/datadog.py +205 -0
  27. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/helicone.py +184 -0
  28. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/langfuse.py +133 -0
  29. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/langsmith.py +85 -0
  30. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/otel.py +250 -0
  31. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/observability/phoenix.py +69 -0
  32. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/py.typed +0 -0
  33. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/rag/__init__.py +6 -0
  34. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/rag/http.py +77 -0
  35. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/rag/langchain.py +182 -0
  36. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/__init__.py +6 -0
  37. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/azure_search.py +237 -0
  38. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/chroma.py +132 -0
  39. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/milvus.py +197 -0
  40. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/opensearch.py +210 -0
  41. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/pgvector.py +140 -0
  42. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/pinecone.py +169 -0
  43. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/qdrant.py +203 -0
  44. sectum_ai_adapters-0.1.1/src/sectum_ai/adapters/vector/weaviate.py +176 -0
@@ -0,0 +1,45 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+
8
+ # Builds / distributions
9
+ build/
10
+ dist/
11
+ *.whl
12
+
13
+ # mkdocs build output
14
+ site/
15
+
16
+ # uv / virtual environments
17
+ .venv/
18
+ venv/
19
+
20
+ # Tooling caches
21
+ .mypy_cache/
22
+ .ruff_cache/
23
+ .pytest_cache/
24
+ .coverage
25
+ .coverage.*
26
+ coverage.xml
27
+ htmlcov/
28
+
29
+ # Editors / OS
30
+ .idea/
31
+ .vscode/
32
+ *.swp
33
+ .DS_Store
34
+
35
+ # Example run artifacts (generated by examples/*/run.sh, incl. the
36
+ # out-residual/ workdir from the docs/samples regeneration recipe)
37
+ examples/*/out/
38
+ examples/*/out-residual/
39
+
40
+ # Sectum CLI default workdir (generated by seed/probe/report; not source)
41
+ .sectum-ai/
42
+ examples/*/.sectum-ai/
43
+
44
+ # Project-local engineering spec (not shared)
45
+ CLAUDE.md
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: sectum-ai-adapters
3
+ Version: 0.1.1
4
+ Summary: Sectum AI - adapters connecting the substrate and probes to real systems.
5
+ Project-URL: Homepage, https://sectum.ai
6
+ Project-URL: Repository, https://github.com/sectum-ai/sectum-ai
7
+ Author: Sectum AI
8
+ License-Expression: Apache-2.0
9
+ Keywords: adapters,ai-security,multi-tenant,verification
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: pydantic>=2.9
17
+ Requires-Dist: sectum-ai-spec
18
+ Provides-Extra: anthropic-tooluse
19
+ Requires-Dist: anthropic>=0.40; extra == 'anthropic-tooluse'
20
+ Provides-Extra: autogen
21
+ Requires-Dist: pyautogen<0.3,>=0.2; extra == 'autogen'
22
+ Provides-Extra: azure-search
23
+ Requires-Dist: azure-core>=1.30; extra == 'azure-search'
24
+ Requires-Dist: azure-search-documents>=11.4; extra == 'azure-search'
25
+ Provides-Extra: chroma
26
+ Requires-Dist: chromadb-client>=0.5; extra == 'chroma'
27
+ Provides-Extra: crewai
28
+ Requires-Dist: crewai>=0.80; extra == 'crewai'
29
+ Provides-Extra: huggingface
30
+ Requires-Dist: accelerate>=1; extra == 'huggingface'
31
+ Requires-Dist: peft>=0.13; extra == 'huggingface'
32
+ Requires-Dist: torch>=2.4; extra == 'huggingface'
33
+ Requires-Dist: transformers>=4.45; extra == 'huggingface'
34
+ Provides-Extra: langfuse
35
+ Requires-Dist: langfuse<4,>=3; extra == 'langfuse'
36
+ Provides-Extra: langgraph
37
+ Requires-Dist: langchain-core>=0.3; extra == 'langgraph'
38
+ Requires-Dist: langgraph>=0.2; extra == 'langgraph'
39
+ Provides-Extra: langsmith
40
+ Requires-Dist: langsmith>=0.1; extra == 'langsmith'
41
+ Provides-Extra: mcp
42
+ Requires-Dist: mcp>=1; extra == 'mcp'
43
+ Provides-Extra: milvus
44
+ Requires-Dist: pymilvus>=2.4; extra == 'milvus'
45
+ Provides-Extra: openai-assistants
46
+ Requires-Dist: openai>=1.50; extra == 'openai-assistants'
47
+ Provides-Extra: opensearch
48
+ Requires-Dist: opensearch-py>=2.4; extra == 'opensearch'
49
+ Provides-Extra: pgvector
50
+ Requires-Dist: psycopg[binary]>=3.3.4; extra == 'pgvector'
51
+ Provides-Extra: phoenix
52
+ Requires-Dist: arize-phoenix-client>=2; extra == 'phoenix'
53
+ Requires-Dist: httpx>=0.27; extra == 'phoenix'
54
+ Provides-Extra: pinecone
55
+ Requires-Dist: pinecone>=5; extra == 'pinecone'
56
+ Provides-Extra: qdrant
57
+ Requires-Dist: qdrant-client>=1.12; extra == 'qdrant'
58
+ Provides-Extra: rag-langchain
59
+ Requires-Dist: langchain-core>=0.3; extra == 'rag-langchain'
60
+ Provides-Extra: redis
61
+ Requires-Dist: redis>=5; extra == 'redis'
62
+ Provides-Extra: weaviate
63
+ Requires-Dist: weaviate-client>=4.9; extra == 'weaviate'
64
+ Description-Content-Type: text/markdown
65
+
66
+ # sectum-ai-adapters
67
+
68
+ The adapter SDK for [Sectum AI](https://github.com/sectum-ai/sectum-ai) —
69
+ the connectors that point the marker substrate and the attack catalog at real
70
+ systems.
71
+
72
+ Every adapter family ships a deterministic in-memory `fake` (used by the unit
73
+ suite and offline runs) plus live backends, each behind a capability-reporting
74
+ interface so probes can declare what they require:
75
+
76
+ - **Vector stores** — pgvector, Chroma, Weaviate, Qdrant, Pinecone, Milvus, OpenSearch, Azure AI Search
77
+ - **RAG pipelines** — generic HTTP, LangChain
78
+ - **Observability** — Langfuse, LangSmith, Phoenix, Helicone, Datadog APM, generic OpenTelemetry
79
+ - **Agents** — LangGraph, AutoGen, CrewAI, OpenAI Assistants, Anthropic tool-use, generic HTTP
80
+ - **MCP** — stdio + streamable-HTTP Model Context Protocol clients
81
+ - **Cache** — Redis
82
+ - **Model** — HuggingFace + PEFT LoRA
83
+
84
+ ```sh
85
+ pip install sectum-ai-adapters
86
+ # live backends are opt-in extras, e.g.:
87
+ pip install "sectum-ai-adapters[pgvector]" # or [redis], [langgraph], [anthropic-tooluse], ...
88
+ ```
89
+
90
+ Most users install the umbrella package [`sectum-ai`](https://pypi.org/project/sectum-ai/)
91
+ instead, which pulls this in automatically.
92
+
93
+ - Adapter configuration reference: <https://docs.sectum.ai>
94
+ - Source: <https://github.com/sectum-ai/sectum-ai>
95
+
96
+ Apache-2.0.
@@ -0,0 +1,31 @@
1
+ # sectum-ai-adapters
2
+
3
+ The adapter SDK for [Sectum AI](https://github.com/sectum-ai/sectum-ai) —
4
+ the connectors that point the marker substrate and the attack catalog at real
5
+ systems.
6
+
7
+ Every adapter family ships a deterministic in-memory `fake` (used by the unit
8
+ suite and offline runs) plus live backends, each behind a capability-reporting
9
+ interface so probes can declare what they require:
10
+
11
+ - **Vector stores** — pgvector, Chroma, Weaviate, Qdrant, Pinecone, Milvus, OpenSearch, Azure AI Search
12
+ - **RAG pipelines** — generic HTTP, LangChain
13
+ - **Observability** — Langfuse, LangSmith, Phoenix, Helicone, Datadog APM, generic OpenTelemetry
14
+ - **Agents** — LangGraph, AutoGen, CrewAI, OpenAI Assistants, Anthropic tool-use, generic HTTP
15
+ - **MCP** — stdio + streamable-HTTP Model Context Protocol clients
16
+ - **Cache** — Redis
17
+ - **Model** — HuggingFace + PEFT LoRA
18
+
19
+ ```sh
20
+ pip install sectum-ai-adapters
21
+ # live backends are opt-in extras, e.g.:
22
+ pip install "sectum-ai-adapters[pgvector]" # or [redis], [langgraph], [anthropic-tooluse], ...
23
+ ```
24
+
25
+ Most users install the umbrella package [`sectum-ai`](https://pypi.org/project/sectum-ai/)
26
+ instead, which pulls this in automatically.
27
+
28
+ - Adapter configuration reference: <https://docs.sectum.ai>
29
+ - Source: <https://github.com/sectum-ai/sectum-ai>
30
+
31
+ Apache-2.0.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sectum-ai-adapters"
7
+ version = "0.1.1"
8
+ description = "Sectum AI - adapters connecting the substrate and probes to real systems."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Sectum AI" }]
13
+ keywords = ["ai-security", "multi-tenant", "adapters", "verification"]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Security",
20
+ ]
21
+ dependencies = [
22
+ "sectum-ai-spec",
23
+ # base.py imports pydantic directly (adapter config models); declare it
24
+ # rather than relying on the transitive dependency via sectum-ai-spec (§13).
25
+ "pydantic>=2.9",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ pgvector = ["psycopg[binary]>=3.3.4"]
30
+ chroma = ["chromadb-client>=0.5"]
31
+ weaviate = ["weaviate-client>=4.9"]
32
+ redis = ["redis>=5"]
33
+ # the Phoenix adapter imports httpx directly (observability/phoenix.py); declare
34
+ # it explicitly rather than relying on arize-phoenix-client to pull it in.
35
+ phoenix = ["arize-phoenix-client>=2", "httpx>=0.27"]
36
+ langfuse = ["langfuse>=3,<4"]
37
+ langsmith = ["langsmith>=0.1"]
38
+ mcp = ["mcp>=1"]
39
+ pinecone = ["pinecone>=5"]
40
+ qdrant = ["qdrant-client>=1.12"]
41
+ milvus = ["pymilvus>=2.4"]
42
+ opensearch = ["opensearch-py>=2.4"]
43
+ azure-search = ["azure-search-documents>=11.4", "azure-core>=1.30"]
44
+ langgraph = ["langgraph>=0.2", "langchain-core>=0.3"]
45
+ autogen = ["pyautogen>=0.2,<0.3"]
46
+ crewai = ["crewai>=0.80"]
47
+ huggingface = ["transformers>=4.45", "peft>=0.13", "torch>=2.4", "accelerate>=1"]
48
+ openai-assistants = ["openai>=1.50"]
49
+ anthropic-tooluse = ["anthropic>=0.40"]
50
+ rag-langchain = ["langchain-core>=0.3"]
51
+ # helicone + datadog observability read over their REST APIs with the standard
52
+ # library (no SDK), so they need no extras group; listed here for discoverability.
53
+
54
+ [project.urls]
55
+ Homepage = "https://sectum.ai"
56
+ Repository = "https://github.com/sectum-ai/sectum-ai"
57
+
58
+ [tool.uv.sources]
59
+ sectum-ai-spec = { workspace = true }
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ only-include = ["src/sectum_ai"]
63
+ sources = ["src"]
@@ -0,0 +1,92 @@
1
+ """Sectum AI adapters: connectors to vector stores, RAG, observability, agents, MCP, and caches.
2
+
3
+ This is the ``sectum_ai.adapters`` namespace package (the engineering spec, section 11).
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError
7
+ from importlib.metadata import version as _dist_version
8
+
9
+ from sectum_ai.adapters.base import (
10
+ Adapter,
11
+ AdapterFamily,
12
+ AdapterRegistry,
13
+ AgentAdapter,
14
+ AgentResult,
15
+ BackupAdapter,
16
+ CacheAdapter,
17
+ Capability,
18
+ EvalSetAdapter,
19
+ MCPAdapter,
20
+ McpResult,
21
+ MemoryAdapter,
22
+ ModelAdapter,
23
+ ObservabilityAdapter,
24
+ RagAnswer,
25
+ RAGPipelineAdapter,
26
+ SearchIndexAdapter,
27
+ TraceHit,
28
+ VectorHit,
29
+ VectorStoreAdapter,
30
+ )
31
+ from sectum_ai.adapters.fakes import (
32
+ FakeAgent,
33
+ FakeBackup,
34
+ FakeCache,
35
+ FakeEvalSet,
36
+ FakeMCP,
37
+ FakeMemory,
38
+ FakeModel,
39
+ FakeObservability,
40
+ FakeRAGPipeline,
41
+ FakeSearchIndex,
42
+ FakeVectorStore,
43
+ )
44
+
45
+ __all__ = [
46
+ "Adapter",
47
+ "AdapterFamily",
48
+ "AdapterRegistry",
49
+ "AgentAdapter",
50
+ "AgentResult",
51
+ "BackupAdapter",
52
+ "CacheAdapter",
53
+ "Capability",
54
+ "EvalSetAdapter",
55
+ "FakeAgent",
56
+ "FakeBackup",
57
+ "FakeCache",
58
+ "FakeEvalSet",
59
+ "FakeMCP",
60
+ "FakeMemory",
61
+ "FakeModel",
62
+ "FakeObservability",
63
+ "FakeRAGPipeline",
64
+ "FakeSearchIndex",
65
+ "FakeVectorStore",
66
+ "MCPAdapter",
67
+ "McpResult",
68
+ "MemoryAdapter",
69
+ "ModelAdapter",
70
+ "ObservabilityAdapter",
71
+ "RAGPipelineAdapter",
72
+ "RagAnswer",
73
+ "SearchIndexAdapter",
74
+ "TraceHit",
75
+ "VectorHit",
76
+ "VectorStoreAdapter",
77
+ "version",
78
+ ]
79
+
80
+
81
+ def version() -> str:
82
+ """Return the installed ``sectum-ai-adapters`` distribution version.
83
+
84
+ Stamped into ``RunResult.adapter_versions`` so an evidence pack attests the
85
+ version of the *adapters* distribution that produced it, not the core CLI's
86
+ (the engineering spec, section 8.2). Falls back to ``0.0.0+unknown`` in an
87
+ uninstalled / editable tree.
88
+ """
89
+ try:
90
+ return _dist_version("sectum-ai-adapters")
91
+ except PackageNotFoundError: # pragma: no cover - only in an uninstalled tree
92
+ return "0.0.0+unknown"
@@ -0,0 +1,10 @@
1
+ """Live agent adapters.
2
+
3
+ Each module here connects to one real agent backend and is imported explicitly
4
+ (for example ``from sectum_ai.adapters.agent.http import HttpAgent``,
5
+ ``from sectum_ai.adapters.agent.langgraph import LangGraphAgent``,
6
+ ``from sectum_ai.adapters.agent.autogen import AutoGenAgent``,
7
+ ``from sectum_ai.adapters.agent.crewai import CrewAIAgent``,
8
+ ``from sectum_ai.adapters.agent.openai_assistants import OpenAIAssistantsAgent``, or
9
+ ``from sectum_ai.adapters.agent.anthropic_tooluse import AnthropicToolUseAgent``).
10
+ """
@@ -0,0 +1,180 @@
1
+ """Live Anthropic Messages-API client backing the tool-use adapter.
2
+
3
+ Imported lazily by ``AnthropicToolUseAgent.connect``. The module imports
4
+ ``anthropic`` at module load, so a downstream import of
5
+ ``sectum_ai.adapters.agent.anthropic_tooluse`` does NOT pull the SDK —
6
+ only construction via ``connect`` does.
7
+
8
+ Implements the ``_AnthropicClient`` protocol the adapter consumes:
9
+
10
+ - ``run_turn`` — calls ``messages.create`` on the supplied conversation
11
+ history; for each ``tool_use`` block in the response, executes the
12
+ registered python callable, appends a ``tool_result`` user message,
13
+ and repeats until ``stop_reason: end_turn``. Returns the final
14
+ assistant text + the list of tool names that fired across the loop.
15
+
16
+ Tool execution: the caller attaches a python callable to each tool
17
+ spec via the ``__sectum_callable__`` attribute. The backend looks the
18
+ callable up by the tool's ``name`` field.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from typing import Any
25
+
26
+ from sectum_ai.spec import AdapterError
27
+
28
+ _MAX_TOOL_USE_ITERATIONS = 16
29
+
30
+
31
+ class LiveAnthropicClient:
32
+ """Live ``_AnthropicClient`` implementation backed by the Anthropic Python SDK.
33
+
34
+ Holds a single ``anthropic.Anthropic`` client plus a registry of
35
+ the python callables that back each registered tool by name.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ api_key: str | None,
42
+ model: str,
43
+ tools: list[Any],
44
+ max_tokens: int,
45
+ system: str,
46
+ ) -> None:
47
+ try:
48
+ from anthropic import Anthropic
49
+ except ImportError as error:
50
+ raise AdapterError(
51
+ "anthropic-tooluse adapter requires the `anthropic` package; "
52
+ "install sectum-ai-adapters[anthropic-tooluse] to enable it"
53
+ ) from error
54
+ self._anthropic = Anthropic(api_key=api_key) if api_key else Anthropic()
55
+ self._model = model
56
+ self._max_tokens = max_tokens
57
+ self._system = system
58
+ # The Anthropic tool spec is a dict carrying name / description /
59
+ # input_schema. Strip the python-callable sidecar before passing
60
+ # it to the SDK (the SDK rejects unknown keys on tool specs).
61
+ self._tools: list[dict[str, Any]] = []
62
+ self._tool_targets: dict[str, Any] = {}
63
+ for tool in tools:
64
+ spec = getattr(tool, "__sectum_tool_spec__", None) or tool
65
+ if not isinstance(spec, dict) or "name" not in spec:
66
+ raise AdapterError(
67
+ "each tool must be (or carry) an Anthropic tool-spec dict "
68
+ "with at least a `name` field; got "
69
+ f"{type(spec).__name__}"
70
+ )
71
+ self._tools.append(spec)
72
+ name = spec.get("name")
73
+ callable_target = getattr(tool, "__sectum_callable__", tool)
74
+ if isinstance(name, str) and name and callable(callable_target):
75
+ self._tool_targets[name] = callable_target
76
+
77
+ def run_turn(self, messages: list[dict[str, Any]]) -> tuple[str, tuple[str, ...]]:
78
+ """Drive the tool-use loop to completion; return (final_text, tool_names)."""
79
+ tool_names: list[str] = []
80
+ loop_messages = list(messages)
81
+ for _ in range(_MAX_TOOL_USE_ITERATIONS):
82
+ response = self._anthropic.messages.create(
83
+ model=self._model,
84
+ max_tokens=self._max_tokens,
85
+ system=self._system,
86
+ tools=self._tools,
87
+ messages=loop_messages,
88
+ )
89
+ content_blocks = getattr(response, "content", []) or []
90
+ tool_uses = [
91
+ block for block in content_blocks if getattr(block, "type", None) == "tool_use"
92
+ ]
93
+ if not tool_uses:
94
+ # No more tool calls; collect the final assistant text and stop.
95
+ final_text = _collect_text(content_blocks)
96
+ return final_text, tuple(tool_names)
97
+
98
+ # Append the assistant's tool_use turn to the loop history so the
99
+ # next messages.create call has the context.
100
+ loop_messages.append(
101
+ {
102
+ "role": "assistant",
103
+ "content": [_block_to_dict(block) for block in content_blocks],
104
+ }
105
+ )
106
+
107
+ # Execute each tool_use block and build the matching
108
+ # tool_result user message.
109
+ tool_results: list[dict[str, Any]] = []
110
+ for block in tool_uses:
111
+ name = getattr(block, "name", None)
112
+ if not isinstance(name, str) or not name:
113
+ continue
114
+ tool_names.append(name)
115
+ tool_use_id = getattr(block, "id", "")
116
+ arguments = getattr(block, "input", None)
117
+ if not isinstance(arguments, dict):
118
+ arguments = {}
119
+ target = self._tool_targets.get(name)
120
+ if target is None:
121
+ result = ""
122
+ else:
123
+ try:
124
+ result = target(**arguments)
125
+ except Exception as error:
126
+ result = f"tool {name} raised {type(error).__name__}: {error}"
127
+ tool_results.append(
128
+ {
129
+ "type": "tool_result",
130
+ "tool_use_id": tool_use_id,
131
+ "content": str(result),
132
+ }
133
+ )
134
+ loop_messages.append({"role": "user", "content": tool_results})
135
+
136
+ raise AdapterError(
137
+ f"anthropic tool-use loop exceeded {_MAX_TOOL_USE_ITERATIONS} iterations"
138
+ )
139
+
140
+
141
+ def _collect_text(content_blocks: list[Any]) -> str:
142
+ """Concatenate every ``text`` block's text into a single string."""
143
+ parts: list[str] = []
144
+ for block in content_blocks:
145
+ if getattr(block, "type", None) != "text":
146
+ continue
147
+ text = getattr(block, "text", "")
148
+ if isinstance(text, str):
149
+ parts.append(text)
150
+ return "".join(parts)
151
+
152
+
153
+ def _block_to_dict(block: Any) -> dict[str, Any]:
154
+ """Convert an SDK content-block object back to its dict form.
155
+
156
+ Anthropic's SDK returns typed block objects on responses but expects
157
+ dict-shaped blocks when echoing them back as assistant history; the
158
+ serialisation has to round-trip without losing the type tag.
159
+ """
160
+ block_type = getattr(block, "type", None)
161
+ if block_type == "text":
162
+ return {"type": "text", "text": getattr(block, "text", "")}
163
+ if block_type == "tool_use":
164
+ input_value = getattr(block, "input", None)
165
+ if isinstance(input_value, str):
166
+ try:
167
+ input_value = json.loads(input_value)
168
+ except json.JSONDecodeError:
169
+ input_value = {}
170
+ if not isinstance(input_value, dict):
171
+ input_value = {}
172
+ return {
173
+ "type": "tool_use",
174
+ "id": getattr(block, "id", ""),
175
+ "name": getattr(block, "name", ""),
176
+ "input": input_value,
177
+ }
178
+ # Unknown block type; fall back to a string projection so the loop
179
+ # does not crash on a future SDK addition.
180
+ return {"type": block_type or "unknown", "text": str(block)}
@@ -0,0 +1,149 @@
1
+ """Live OpenAI Assistants client implementation.
2
+
3
+ Imported lazily by ``OpenAIAssistantsAgent.connect``. The module imports
4
+ ``openai`` at module load, so a downstream import of
5
+ ``sectum_ai.adapters.agent.openai_assistants`` does NOT pull the SDK —
6
+ only construction via ``connect`` does.
7
+
8
+ Implements the ``_AssistantsClient`` protocol the adapter consumes:
9
+
10
+ - ``create_assistant`` — creates the persistent Assistant (model +
11
+ instructions + tools); returns its id
12
+ - ``create_thread`` — creates a fresh conversation Thread; returns its
13
+ id (the adapter caches one per tenant)
14
+ - ``add_user_message`` — posts a user message into the Thread
15
+ - ``run_until_complete`` — starts a Run and drives it through the
16
+ tool-call resolution loop until ``completed`` (or a terminal
17
+ failure); returns ``(final_assistant_text, tool_call_names)``
18
+
19
+ Tool execution: the live backend resolves each ``requires_action``
20
+ event by reading the tool name + arguments off the run, calling the
21
+ caller-supplied Python callable mapped under the same name, and
22
+ posting the result back via ``submit_tool_outputs``. The caller
23
+ attaches a python callable to each tool spec via the
24
+ ``__sectum_callable__`` attribute so the backend can find it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import time
31
+ from typing import Any
32
+
33
+ from sectum_ai.adapters.agent.openai_assistants import _poll_delay_ms
34
+ from sectum_ai.spec import AdapterError
35
+
36
+
37
+ class LiveAssistantsClient:
38
+ """Live ``_AssistantsClient`` implementation backed by the OpenAI Python SDK.
39
+
40
+ Holds a single ``openai.OpenAI`` client and a registry of the
41
+ Python callables that back each tool name on the configured
42
+ Assistant.
43
+ """
44
+
45
+ def __init__(self, api_key: str | None = None) -> None:
46
+ try:
47
+ from openai import OpenAI
48
+ except ImportError as error:
49
+ raise AdapterError(
50
+ "openai-assistants adapter requires the `openai` package; "
51
+ "install sectum-ai-adapters[openai-assistants] to enable it"
52
+ ) from error
53
+ self._openai = OpenAI(api_key=api_key) if api_key else OpenAI()
54
+ self._tools: dict[str, Any] = {}
55
+
56
+ def create_assistant(
57
+ self,
58
+ model: str,
59
+ tools: list[Any],
60
+ *,
61
+ name: str,
62
+ instructions: str,
63
+ ) -> str:
64
+ """Create a persistent Assistant + register the tool callables locally."""
65
+ tool_specs: list[dict[str, Any]] = []
66
+ for tool in tools:
67
+ spec = getattr(tool, "__sectum_tool_spec__", None) or tool
68
+ if not isinstance(spec, dict) or "function" not in spec:
69
+ raise AdapterError(
70
+ "each tool must carry a `__sectum_tool_spec__` dict with the "
71
+ "OpenAI function-tool schema, or be a tool-spec dict itself"
72
+ )
73
+ tool_specs.append(spec)
74
+ function_name = spec.get("function", {}).get("name")
75
+ callable_target = getattr(tool, "__sectum_callable__", tool)
76
+ if function_name and callable(callable_target):
77
+ self._tools[function_name] = callable_target
78
+ assistant = self._openai.beta.assistants.create(
79
+ model=model,
80
+ name=name,
81
+ instructions=instructions,
82
+ tools=tool_specs,
83
+ )
84
+ return str(assistant.id)
85
+
86
+ def create_thread(self) -> str:
87
+ thread = self._openai.beta.threads.create()
88
+ return str(thread.id)
89
+
90
+ def add_user_message(self, thread_id: str, content: str) -> None:
91
+ self._openai.beta.threads.messages.create(thread_id=thread_id, role="user", content=content)
92
+
93
+ def run_until_complete(self, thread_id: str, assistant_id: str) -> tuple[str, tuple[str, ...]]:
94
+ run = self._openai.beta.threads.runs.create(thread_id=thread_id, assistant_id=assistant_id)
95
+ tool_names: list[str] = []
96
+ attempt = 0
97
+ while True:
98
+ run = self._openai.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run.id)
99
+ status = getattr(run, "status", None)
100
+ if status == "completed":
101
+ break
102
+ if status in ("failed", "cancelled", "expired"):
103
+ raise AdapterError(
104
+ f"openai assistants run terminated in {status!r}: "
105
+ f"{getattr(run, 'last_error', None)}"
106
+ )
107
+ if status == "requires_action":
108
+ outputs = []
109
+ required = getattr(run, "required_action", None)
110
+ tool_calls = (
111
+ getattr(getattr(required, "submit_tool_outputs", None), "tool_calls", [])
112
+ if required is not None
113
+ else []
114
+ )
115
+ for call in tool_calls:
116
+ function = getattr(call, "function", None)
117
+ if function is None:
118
+ continue
119
+ name = getattr(function, "name", None)
120
+ if not isinstance(name, str) or not name:
121
+ continue
122
+ tool_names.append(name)
123
+ args_raw = getattr(function, "arguments", "") or "{}"
124
+ try:
125
+ args = json.loads(args_raw)
126
+ except json.JSONDecodeError:
127
+ args = {}
128
+ target = self._tools.get(name)
129
+ if target is None:
130
+ result: Any = ""
131
+ else:
132
+ result = target(**args)
133
+ outputs.append({"tool_call_id": getattr(call, "id", ""), "output": str(result)})
134
+ self._openai.beta.threads.runs.submit_tool_outputs(
135
+ thread_id=thread_id, run_id=run.id, tool_outputs=outputs
136
+ )
137
+ time.sleep(_poll_delay_ms(attempt) / 1000.0)
138
+ attempt += 1
139
+
140
+ messages = self._openai.beta.threads.messages.list(thread_id=thread_id, order="desc")
141
+ for message in messages.data:
142
+ if getattr(message, "role", None) != "assistant":
143
+ continue
144
+ content_blocks = getattr(message, "content", []) or []
145
+ for block in content_blocks:
146
+ text = getattr(getattr(block, "text", None), "value", None)
147
+ if isinstance(text, str) and text:
148
+ return text, tuple(tool_names)
149
+ return "", tuple(tool_names)