dystopic 0.6.0__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 (98) hide show
  1. dystopic/__init__.py +65 -0
  2. dystopic/_logging.py +32 -0
  3. dystopic/_secret_store.py +102 -0
  4. dystopic/_transport.py +175 -0
  5. dystopic/_version.py +3 -0
  6. dystopic/artifacts.py +250 -0
  7. dystopic/async_client.py +1096 -0
  8. dystopic/cli/__init__.py +27 -0
  9. dystopic/cli/_agent_payload.py +374 -0
  10. dystopic/cli/_agent_wizard.py +367 -0
  11. dystopic/cli/_agents_cmd.py +457 -0
  12. dystopic/cli/_auth_login_browser.py +170 -0
  13. dystopic/cli/_ci_init_cmd.py +271 -0
  14. dystopic/cli/_ci_manage_cmd.py +275 -0
  15. dystopic/cli/_ci_report.py +583 -0
  16. dystopic/cli/_ci_review_cmd.py +1665 -0
  17. dystopic/cli/_cli_extra.py +29 -0
  18. dystopic/cli/_constants.py +7 -0
  19. dystopic/cli/_dystopic_yaml.py +176 -0
  20. dystopic/cli/_ephemeral.py +57 -0
  21. dystopic/cli/_gate_norm.py +52 -0
  22. dystopic/cli/_gha.py +127 -0
  23. dystopic/cli/_junit.py +67 -0
  24. dystopic/cli/_mcp_shim_entry.py +23 -0
  25. dystopic/cli/_odyssey_cli.py +174 -0
  26. dystopic/cli/_odyssey_group.py +31 -0
  27. dystopic/cli/_progress.py +101 -0
  28. dystopic/cli/_progress_rich.py +59 -0
  29. dystopic/cli/_project_config.py +499 -0
  30. dystopic/cli/_repo_cmd.py +159 -0
  31. dystopic/cli/_runs_cmd.py +47 -0
  32. dystopic/cli/_runs_export.py +60 -0
  33. dystopic/cli/_scenarios_cmd.py +137 -0
  34. dystopic/cli/_scorer_merge.py +101 -0
  35. dystopic/cli/_seed_csv.py +40 -0
  36. dystopic/cli/_seeds_cmd.py +108 -0
  37. dystopic/cli/_suites_cmd.py +114 -0
  38. dystopic/cli/_test_cmd.py +279 -0
  39. dystopic/cli/_test_review_cmd.py +389 -0
  40. dystopic/cli/main.py +2061 -0
  41. dystopic/client.py +2255 -0
  42. dystopic/config.py +161 -0
  43. dystopic/errors.py +89 -0
  44. dystopic/odyssey/__init__.py +218 -0
  45. dystopic/odyssey/_apierrors.py +99 -0
  46. dystopic/odyssey/_constants.py +31 -0
  47. dystopic/odyssey/_debug.py +203 -0
  48. dystopic/odyssey/adapters/__init__.py +26 -0
  49. dystopic/odyssey/adapters/_actor.py +116 -0
  50. dystopic/odyssey/adapters/_fidelity.py +29 -0
  51. dystopic/odyssey/adapters/anthropic.py +377 -0
  52. dystopic/odyssey/adapters/autogen.py +18 -0
  53. dystopic/odyssey/adapters/crewai.py +18 -0
  54. dystopic/odyssey/adapters/langchain.py +964 -0
  55. dystopic/odyssey/adapters/metagpt.py +18 -0
  56. dystopic/odyssey/adapters/openai_agents.py +945 -0
  57. dystopic/odyssey/adapters/strands.py +328 -0
  58. dystopic/odyssey/auth.py +86 -0
  59. dystopic/odyssey/cli/__init__.py +25 -0
  60. dystopic/odyssey/cli/_creds.py +23 -0
  61. dystopic/odyssey/cli/_templates.py +753 -0
  62. dystopic/odyssey/cli/agents.py +288 -0
  63. dystopic/odyssey/cli/dev.py +892 -0
  64. dystopic/odyssey/cli/doctor.py +789 -0
  65. dystopic/odyssey/cli/dump_agent.py +297 -0
  66. dystopic/odyssey/cli/init.py +214 -0
  67. dystopic/odyssey/cli/main.py +227 -0
  68. dystopic/odyssey/cli/mcp.py +202 -0
  69. dystopic/odyssey/cli/tunnels.py +445 -0
  70. dystopic/odyssey/context.py +133 -0
  71. dystopic/odyssey/envelope.py +463 -0
  72. dystopic/odyssey/fidelity.py +185 -0
  73. dystopic/odyssey/handler.py +383 -0
  74. dystopic/odyssey/headers.py +25 -0
  75. dystopic/odyssey/labels.py +79 -0
  76. dystopic/odyssey/lifecycle.py +111 -0
  77. dystopic/odyssey/mcp.py +150 -0
  78. dystopic/odyssey/mcp_local.py +65 -0
  79. dystopic/odyssey/mcp_rewrite.py +135 -0
  80. dystopic/odyssey/mcp_shim.py +476 -0
  81. dystopic/odyssey/mcp_stdio.py +194 -0
  82. dystopic/odyssey/proxy.py +911 -0
  83. dystopic/odyssey/reachability.py +69 -0
  84. dystopic/odyssey/registration.py +1468 -0
  85. dystopic/odyssey/response.py +143 -0
  86. dystopic/odyssey/tool_endpoints.py +783 -0
  87. dystopic/odyssey/tools.py +132 -0
  88. dystopic/odyssey/topology.py +136 -0
  89. dystopic/odyssey/traces.py +632 -0
  90. dystopic/odyssey/version_diff.py +13 -0
  91. dystopic/py.typed +0 -0
  92. dystopic/types.py +230 -0
  93. dystopic/version_diff.py +747 -0
  94. dystopic-0.6.0.dist-info/METADATA +567 -0
  95. dystopic-0.6.0.dist-info/RECORD +98 -0
  96. dystopic-0.6.0.dist-info/WHEEL +4 -0
  97. dystopic-0.6.0.dist-info/entry_points.txt +4 -0
  98. dystopic-0.6.0.dist-info/licenses/LICENSE +13 -0
dystopic/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ from ._logging import enable_debug_logging, get_logger
2
+ from ._version import __version__
3
+ from .artifacts import (
4
+ ARTIFACT_BUNDLE_TEMPLATE_KINDS,
5
+ ARTIFACT_BUNDLE_TYPE,
6
+ ArtifactValidationError,
7
+ artifact_bundle_template,
8
+ load_artifact_bundle,
9
+ validate_artifact_bundle,
10
+ )
11
+ from .async_client import AsyncDystopicClient
12
+ from .client import DEFAULT_BASE_URL, DystopicClient
13
+ from .config import (
14
+ clear_config,
15
+ config_path,
16
+ load_config,
17
+ resolve_credentials,
18
+ save_config,
19
+ unset_config_key,
20
+ )
21
+ from .errors import (
22
+ AuthenticationError,
23
+ ConflictError,
24
+ DystopicAPIError,
25
+ DystopicConnectionError,
26
+ DystopicError,
27
+ DystopicTimeoutError,
28
+ ForbiddenError,
29
+ NotFoundError,
30
+ RateLimitError,
31
+ ServerError,
32
+ ValidationError,
33
+ )
34
+
35
+ __all__ = [
36
+ "ARTIFACT_BUNDLE_TYPE",
37
+ "ARTIFACT_BUNDLE_TEMPLATE_KINDS",
38
+ "ArtifactValidationError",
39
+ "AsyncDystopicClient",
40
+ "AuthenticationError",
41
+ "ConflictError",
42
+ "DEFAULT_BASE_URL",
43
+ "ForbiddenError",
44
+ "NotFoundError",
45
+ "DystopicAPIError",
46
+ "DystopicConnectionError",
47
+ "DystopicClient",
48
+ "DystopicError",
49
+ "DystopicTimeoutError",
50
+ "RateLimitError",
51
+ "ServerError",
52
+ "ValidationError",
53
+ "__version__",
54
+ "artifact_bundle_template",
55
+ "clear_config",
56
+ "config_path",
57
+ "enable_debug_logging",
58
+ "get_logger",
59
+ "load_artifact_bundle",
60
+ "load_config",
61
+ "resolve_credentials",
62
+ "save_config",
63
+ "unset_config_key",
64
+ "validate_artifact_bundle",
65
+ ]
dystopic/_logging.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ _LOGGER_NAME = "dystopic"
7
+ _DEBUG_ENV_VAR = "DYSTOPIC_DEBUG"
8
+
9
+
10
+ def get_logger() -> logging.Logger:
11
+ """Return the SDK's root logger.
12
+
13
+ Library callers can configure handlers/level via standard ``logging``
14
+ APIs. By default the logger has a NullHandler so importing the SDK
15
+ does not produce log output unless the caller opts in.
16
+ """
17
+ logger = logging.getLogger(_LOGGER_NAME)
18
+ if not logger.handlers:
19
+ logger.addHandler(logging.NullHandler())
20
+ if os.getenv(_DEBUG_ENV_VAR) and logger.level == logging.NOTSET:
21
+ logger.setLevel(logging.DEBUG)
22
+ return logger
23
+
24
+
25
+ def enable_debug_logging() -> None:
26
+ """Convenience helper for CLI/--verbose: stream DEBUG records to stderr."""
27
+ logger = logging.getLogger(_LOGGER_NAME)
28
+ logger.setLevel(logging.DEBUG)
29
+ if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
30
+ handler = logging.StreamHandler()
31
+ handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
32
+ logger.addHandler(handler)
@@ -0,0 +1,102 @@
1
+ """OS-keyring-backed storage for the Dystopic API key.
2
+
3
+ The only secret we persist is the API key. This module owns every interaction
4
+ with the ``keyring`` library plus the decision to fall back to the plaintext
5
+ ``0600`` config file when no usable keychain exists. ``keyring`` is imported
6
+ lazily and every call is guarded so an SDK-only consumer (or a headless box)
7
+ degrades to the file rather than crashing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from urllib.parse import urlsplit, urlunsplit
15
+
16
+ __all__ = ["SERVICE", "account_for", "get_secret", "set_secret", "delete_secret"]
17
+
18
+ SERVICE = "dystopic"
19
+
20
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
21
+
22
+
23
+ def account_for(base_url: str) -> str:
24
+ """Canonical keyring account for a base URL.
25
+
26
+ Must be byte-identical on the read and write paths, so we lowercase the
27
+ scheme+host, drop a default port, and strip a trailing slash. A scheme-less
28
+ input (e.g. ``localhost:8000`` or ``api.pipelines.tech``) is treated as
29
+ ``https://…`` so ``urlsplit`` populates the host instead of mis-parsing it.
30
+ """
31
+ raw = base_url.strip()
32
+ if "://" not in raw:
33
+ raw = "https://" + raw
34
+ parts = urlsplit(raw)
35
+ scheme = (parts.scheme or "https").lower()
36
+ host = (parts.hostname or "").lower()
37
+ port = parts.port
38
+ netloc = host if port in (None, _DEFAULT_PORTS.get(scheme)) else f"{host}:{port}"
39
+ path = parts.path.rstrip("/")
40
+ return urlunsplit((scheme, netloc, path, "", ""))
41
+
42
+
43
+ def _force_disabled() -> bool:
44
+ """True when we must not touch the keychain (opt-out / CI / headless Linux)."""
45
+ if os.getenv("DYSTOPIC_DISABLE_KEYRING"):
46
+ return True
47
+ if os.getenv("CI"):
48
+ return True
49
+ if sys.platform.startswith("linux") and not os.getenv("DBUS_SESSION_BUS_ADDRESS"):
50
+ return True
51
+ return False
52
+
53
+
54
+ def get_secret(account: str) -> str | None:
55
+ """Return the stored secret, or None on miss / no usable backend."""
56
+ if _force_disabled():
57
+ return None
58
+ try:
59
+ import keyring
60
+
61
+ return keyring.get_password(SERVICE, account)
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ def set_secret(account: str, secret: str) -> bool:
67
+ """Store ``secret`` and verify it by reading back.
68
+
69
+ Returns True ONLY when the value is confirmed present in the keychain — the
70
+ caller relies on this before deleting any plaintext copy. Best-effort cleanup
71
+ on a partial/failed write.
72
+ """
73
+ if _force_disabled():
74
+ return False
75
+ try:
76
+ import keyring
77
+ except Exception:
78
+ return False
79
+ try:
80
+ keyring.set_password(SERVICE, account, secret)
81
+ stored = keyring.get_password(SERVICE, account)
82
+ except Exception:
83
+ return False
84
+ if stored != secret:
85
+ try:
86
+ keyring.delete_password(SERVICE, account)
87
+ except Exception:
88
+ pass
89
+ return False
90
+ return True
91
+
92
+
93
+ def delete_secret(account: str) -> None:
94
+ """Best-effort removal of the stored secret. Never raises."""
95
+ if _force_disabled():
96
+ return
97
+ try:
98
+ import keyring
99
+
100
+ keyring.delete_password(SERVICE, account)
101
+ except Exception:
102
+ pass
dystopic/_transport.py ADDED
@@ -0,0 +1,175 @@
1
+ """Shared HTTP transport helpers used by both the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import sys
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ._logging import get_logger
13
+ from ._version import __version__
14
+ from .config import DEFAULT_BASE_URL
15
+ from .errors import (
16
+ DystopicAPIError,
17
+ DystopicError,
18
+ RateLimitError,
19
+ error_for_status,
20
+ )
21
+
22
+ _LOGGER = get_logger()
23
+
24
+ RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
25
+ SAFE_RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
26
+ TERMINAL_STATUSES = frozenset(
27
+ {
28
+ "completed",
29
+ "failed",
30
+ "cancelled",
31
+ "partially_cancelled",
32
+ "completed_with_partial_results",
33
+ }
34
+ )
35
+
36
+
37
+ def build_user_agent() -> str:
38
+ py_impl = platform.python_implementation()
39
+ py_version = "{}.{}.{}".format(*sys.version_info[:3])
40
+ return f"dystopic/{__version__} {py_impl}/{py_version} httpx/{httpx.__version__}"
41
+
42
+
43
+ USER_AGENT = build_user_agent()
44
+
45
+
46
+ def normalize_base_url(base_url: str) -> str:
47
+ return base_url.rstrip("/")
48
+
49
+
50
+ def url_for(base_url: str, path: str) -> str:
51
+ normalized = path if path.startswith("/") else f"/{path}"
52
+ return f"{base_url}{normalized}"
53
+
54
+
55
+ def build_headers(
56
+ api_key: str | None,
57
+ *,
58
+ accept: str = "application/json",
59
+ idempotency_key: str | None = None,
60
+ extra: Mapping[str, str] | None = None,
61
+ ) -> dict[str, str]:
62
+ headers: dict[str, str] = {
63
+ "Accept": accept,
64
+ "User-Agent": USER_AGENT,
65
+ }
66
+ if api_key:
67
+ headers["Authorization"] = f"Bearer {api_key}"
68
+ if idempotency_key:
69
+ headers["Idempotency-Key"] = idempotency_key
70
+ if extra:
71
+ headers.update(extra)
72
+ return headers
73
+
74
+
75
+ def should_retry(
76
+ method: str,
77
+ response: httpx.Response,
78
+ attempt: int,
79
+ max_retries: int,
80
+ has_idempotency_key: bool,
81
+ ) -> bool:
82
+ if attempt >= max_retries:
83
+ return False
84
+ if response.status_code not in RETRYABLE_STATUS_CODES:
85
+ return False
86
+ return method.upper() in SAFE_RETRY_METHODS or has_idempotency_key
87
+
88
+
89
+ def retry_delay(response: httpx.Response, attempt: int) -> float:
90
+ retry_after = response.headers.get("Retry-After")
91
+ if retry_after:
92
+ try:
93
+ return max(0.0, float(retry_after))
94
+ except ValueError:
95
+ pass
96
+ return float(min(2**attempt, 30))
97
+
98
+
99
+ def parse_retry_after(response: httpx.Response) -> float | None:
100
+ retry_after = response.headers.get("Retry-After")
101
+ if retry_after is None:
102
+ return None
103
+ try:
104
+ return max(0.0, float(retry_after))
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def raise_api_error(response: httpx.Response) -> None:
110
+ """Pick the most specific DystopicAPIError subclass for ``response``."""
111
+ try:
112
+ body: Any = response.json()
113
+ except ValueError:
114
+ body = response.text
115
+
116
+ error_cls = error_for_status(response.status_code)
117
+ message = error_message(body)
118
+
119
+ _LOGGER.debug(
120
+ "dystopic api error: %s %s -> %s %s",
121
+ response.request.method if response.request else "?",
122
+ response.request.url if response.request else "?",
123
+ response.status_code,
124
+ message,
125
+ )
126
+
127
+ if error_cls is RateLimitError:
128
+ raise RateLimitError(
129
+ response.status_code,
130
+ message,
131
+ body=body,
132
+ retry_after=parse_retry_after(response),
133
+ )
134
+ raise error_cls(response.status_code, message, body=body)
135
+
136
+
137
+ def error_message(body: Any) -> str:
138
+ if not isinstance(body, dict):
139
+ return str(body)
140
+
141
+ detail = body.get("detail", body)
142
+ if isinstance(detail, str):
143
+ return detail
144
+ if isinstance(detail, list):
145
+ formatted_errors = []
146
+ for item in detail:
147
+ if not isinstance(item, dict):
148
+ formatted_errors.append(str(item))
149
+ continue
150
+ loc = item.get("loc")
151
+ location = ".".join(str(part) for part in loc) if isinstance(loc, list) else None
152
+ msg = item.get("msg", item)
153
+ formatted_errors.append(f"{location}: {msg}" if location else str(msg))
154
+ return "; ".join(formatted_errors)
155
+ return str(detail)
156
+
157
+
158
+ __all__ = [
159
+ "DEFAULT_BASE_URL",
160
+ "DystopicAPIError",
161
+ "DystopicError",
162
+ "RETRYABLE_STATUS_CODES",
163
+ "SAFE_RETRY_METHODS",
164
+ "TERMINAL_STATUSES",
165
+ "USER_AGENT",
166
+ "build_headers",
167
+ "build_user_agent",
168
+ "error_message",
169
+ "normalize_base_url",
170
+ "parse_retry_after",
171
+ "raise_api_error",
172
+ "retry_delay",
173
+ "should_retry",
174
+ "url_for",
175
+ ]
dystopic/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.6.0"
dystopic/artifacts.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .errors import DystopicError
9
+
10
+ ARTIFACT_BUNDLE_TYPE = "dystopic.artifact.bundle.v1"
11
+ ARTIFACT_BUNDLE_TEMPLATE_KINDS = {
12
+ "bundle",
13
+ "prompts",
14
+ "criteria",
15
+ "evaluations",
16
+ "tool-endpoints",
17
+ "tool-bindings",
18
+ "tool-ground-truths",
19
+ }
20
+ ARTIFACT_BUNDLE_SECTIONS = (
21
+ "prompts",
22
+ "criteria",
23
+ "evaluations",
24
+ "experiments",
25
+ "tool_endpoints",
26
+ "tool_bindings",
27
+ "tool_ground_truths",
28
+ )
29
+ TEMPLATE_ORG_ID = 2
30
+ TEMPLATE_WORKFLOW_ID = 3086
31
+ TEMPLATE_CRITERION_IDS = {
32
+ "exact_match": 7,
33
+ "contains_keywords": 8,
34
+ "safety": 9,
35
+ }
36
+
37
+
38
+ class ArtifactValidationError(DystopicError):
39
+ """Raised when an artifact manifest cannot be imported safely."""
40
+
41
+
42
+ def artifact_bundle_template(kind: str = "prompts") -> dict[str, Any]:
43
+ if kind not in ARTIFACT_BUNDLE_TEMPLATE_KINDS:
44
+ raise ArtifactValidationError(
45
+ "Artifact bundle template kind must be one of: "
46
+ f"{sorted(ARTIFACT_BUNDLE_TEMPLATE_KINDS)}"
47
+ )
48
+
49
+ # Keep templates focused: one bundle should normally import one artifact family.
50
+ normalized_kind = "prompts" if kind == "bundle" else kind
51
+ bundle = {
52
+ "artifact_type": ARTIFACT_BUNDLE_TYPE,
53
+ "name": f"demo-{normalized_kind}-bundle",
54
+ "description": f"Portable {normalized_kind} configs that can be versioned in git.",
55
+ "prompts": [],
56
+ "criteria": [],
57
+ "evaluations": [],
58
+ "experiments": [],
59
+ "tool_endpoints": [],
60
+ "tool_bindings": [],
61
+ "tool_ground_truths": [],
62
+ }
63
+ if normalized_kind == "prompts":
64
+ bundle["prompts"] = [
65
+ {
66
+ "name": "Concise demo prompt",
67
+ "description": "Created from a Dystopic artifact bundle",
68
+ "prompt_text": "Write a concise answer for {{input}}.",
69
+ "prompt_placeholders": [{"name": "input"}],
70
+ "role": "user",
71
+ "scope_type": "org",
72
+ "org_id": TEMPLATE_ORG_ID,
73
+ },
74
+ {
75
+ "name": "Detailed demo prompt",
76
+ "description": "Created from a Dystopic artifact bundle",
77
+ "prompt_text": "Write a detailed answer for {{input}} with examples.",
78
+ "prompt_placeholders": [{"name": "input"}],
79
+ "role": "user",
80
+ "scope_type": "org",
81
+ "org_id": TEMPLATE_ORG_ID,
82
+ },
83
+ ]
84
+ elif normalized_kind == "criteria":
85
+ bundle["criteria"] = [
86
+ {
87
+ "name": "Exact Match",
88
+ "display_label": "Exact Match",
89
+ "type": "programmatic",
90
+ "config": {"subtype": "exact_match"},
91
+ "output_schema": {"type": "boolean"},
92
+ "scope_type": "org",
93
+ "org_id": TEMPLATE_ORG_ID,
94
+ },
95
+ {
96
+ "name": "Contains Keywords",
97
+ "display_label": "Contains Keywords",
98
+ "type": "programmatic",
99
+ "config": {
100
+ "subtype": "contains_keywords",
101
+ "keywords": ["required phrase", "important keyword"],
102
+ "mode": "any",
103
+ "case_sensitive": False,
104
+ },
105
+ "output_schema": {"type": "boolean"},
106
+ "scope_type": "org",
107
+ "org_id": TEMPLATE_ORG_ID,
108
+ },
109
+ ]
110
+ elif normalized_kind == "evaluations":
111
+ bundle["evaluations"] = [
112
+ {
113
+ "name": "Quality evaluation",
114
+ "description": "Created from a Dystopic artifact bundle",
115
+ "criteria": [
116
+ {"criterion_id": TEMPLATE_CRITERION_IDS["exact_match"]},
117
+ {"criterion_id": TEMPLATE_CRITERION_IDS["contains_keywords"]},
118
+ ],
119
+ "scope_type": "org",
120
+ "org_id": TEMPLATE_ORG_ID,
121
+ },
122
+ {
123
+ "name": "Safety evaluation",
124
+ "description": "Created from a Dystopic artifact bundle",
125
+ "criteria": [{"criterion_id": TEMPLATE_CRITERION_IDS["safety"]}],
126
+ "scope_type": "org",
127
+ "org_id": TEMPLATE_ORG_ID,
128
+ },
129
+ ]
130
+ elif normalized_kind == "tool-endpoints":
131
+ bundle["tool_endpoints"] = [
132
+ {
133
+ "org_id": TEMPLATE_ORG_ID,
134
+ "name": "Docs MCP endpoint",
135
+ "description": "MCP server that exposes documentation search tools",
136
+ "endpoint_type": "mcp_server",
137
+ "url": "https://tools.example.com/mcp",
138
+ "auth_type": "none",
139
+ "auth_config": {},
140
+ },
141
+ {
142
+ "org_id": TEMPLATE_ORG_ID,
143
+ "name": "Support MCP endpoint",
144
+ "description": "MCP server that exposes support lookup tools",
145
+ "endpoint_type": "mcp_server",
146
+ "url": "https://support-tools.example.com/mcp",
147
+ "auth_type": "none",
148
+ "auth_config": {},
149
+ },
150
+ ]
151
+ elif normalized_kind == "tool-bindings":
152
+ bundle["tool_bindings"] = [
153
+ {
154
+ "workflow_id": TEMPLATE_WORKFLOW_ID,
155
+ "node_id": "replace-with-node-id",
156
+ "field_id": "replace-with-field-id",
157
+ "endpoint_id": "replace-with-tool-endpoint-id",
158
+ "selected_tools": ["search_docs"],
159
+ "max_tool_rounds": 3,
160
+ },
161
+ {
162
+ "workflow_id": TEMPLATE_WORKFLOW_ID,
163
+ "node_id": "replace-with-node-id",
164
+ "field_id": "replace-with-second-field-id",
165
+ "endpoint_id": "replace-with-tool-endpoint-id",
166
+ "selected_tools": ["fetch_url"],
167
+ "max_tool_rounds": 2,
168
+ },
169
+ ]
170
+ elif normalized_kind == "tool-ground-truths":
171
+ bundle["tool_ground_truths"] = [
172
+ {
173
+ "workflow_id": TEMPLATE_WORKFLOW_ID,
174
+ "node_id": "replace-with-node-id",
175
+ "field_id": "replace-with-field-id",
176
+ "expected_tools": [{"name": "search_docs"}],
177
+ "excluded_tools": [],
178
+ "ordering_matters": False,
179
+ "source": "manual",
180
+ "confidence": 1.0,
181
+ },
182
+ {
183
+ "workflow_id": TEMPLATE_WORKFLOW_ID,
184
+ "node_id": "replace-with-node-id",
185
+ "field_id": "replace-with-second-field-id",
186
+ "expected_tools": [{"name": "fetch_url"}],
187
+ "excluded_tools": [{"name": "delete_record"}],
188
+ "ordering_matters": True,
189
+ "source": "manual",
190
+ "confidence": 0.9,
191
+ },
192
+ ]
193
+ return bundle
194
+
195
+
196
+ def load_artifact_bundle(path: str | Path) -> dict[str, Any]:
197
+ with Path(path).open(encoding="utf-8") as handle:
198
+ payload = json.load(handle)
199
+ if not isinstance(payload, dict):
200
+ raise ArtifactValidationError("Artifact bundle file must contain a JSON object")
201
+ return validate_artifact_bundle(payload)
202
+
203
+
204
+ def validate_artifact_bundle(payload: Mapping[str, Any]) -> dict[str, Any]:
205
+ if payload.get("artifact_type") != ARTIFACT_BUNDLE_TYPE:
206
+ raise ArtifactValidationError(
207
+ f"Artifact bundle must include artifact_type='{ARTIFACT_BUNDLE_TYPE}'"
208
+ )
209
+
210
+ bundle = dict(payload)
211
+ for section in ARTIFACT_BUNDLE_SECTIONS:
212
+ value = bundle.get(section, [])
213
+ if not isinstance(value, list):
214
+ raise ArtifactValidationError(f"Artifact bundle field '{section}' must be a list")
215
+ for index, item in enumerate(value):
216
+ if not isinstance(item, dict):
217
+ raise ArtifactValidationError(
218
+ f"Artifact bundle field '{section}[{index}]' must be a JSON object"
219
+ )
220
+ bundle[section] = value
221
+
222
+ for index, experiment in enumerate(bundle["experiments"]):
223
+ _require_experiment_field(experiment, index, "project_id")
224
+ _require_experiment_field(experiment, index, "workflow_id")
225
+ payload_value = experiment.get("payload")
226
+ if not isinstance(payload_value, dict):
227
+ raise ArtifactValidationError(
228
+ f"Artifact bundle experiment[{index}] must include a JSON object payload"
229
+ )
230
+
231
+ for index, endpoint in enumerate(bundle["tool_endpoints"]):
232
+ _require_artifact_field(endpoint, "tool_endpoints", index, "org_id")
233
+ for index, binding in enumerate(bundle["tool_bindings"]):
234
+ _require_artifact_field(binding, "tool_bindings", index, "workflow_id")
235
+ for index, ground_truth in enumerate(bundle["tool_ground_truths"]):
236
+ _require_artifact_field(ground_truth, "tool_ground_truths", index, "workflow_id")
237
+
238
+ return bundle
239
+
240
+
241
+ def _require_experiment_field(experiment: Mapping[str, Any], index: int, field: str) -> None:
242
+ if experiment.get(field) is None:
243
+ raise ArtifactValidationError(f"Artifact bundle experiment[{index}] must include '{field}'")
244
+
245
+
246
+ def _require_artifact_field(
247
+ artifact: Mapping[str, Any], section: str, index: int, field: str
248
+ ) -> None:
249
+ if artifact.get(field) is None:
250
+ raise ArtifactValidationError(f"{section}[{index}] must include '{field}'")