sootool 0.1.1__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 (193) hide show
  1. sootool/__init__.py +11 -0
  2. sootool/__main__.py +245 -0
  3. sootool/core/__init__.py +0 -0
  4. sootool/core/audit.py +42 -0
  5. sootool/core/batch.py +129 -0
  6. sootool/core/calc.py +748 -0
  7. sootool/core/cast.py +99 -0
  8. sootool/core/decimal_ops.py +44 -0
  9. sootool/core/determinism.py +63 -0
  10. sootool/core/errors.py +68 -0
  11. sootool/core/locale_kr.py +157 -0
  12. sootool/core/pipeline.py +295 -0
  13. sootool/core/registry.py +86 -0
  14. sootool/core/rounding.py +38 -0
  15. sootool/core/types.py +47 -0
  16. sootool/core/units.py +32 -0
  17. sootool/middleware/__init__.py +5 -0
  18. sootool/middleware/auth.py +55 -0
  19. sootool/middleware/cors.py +23 -0
  20. sootool/middleware/locale.py +62 -0
  21. sootool/middleware/logging.py +56 -0
  22. sootool/middleware/request_id.py +25 -0
  23. sootool/modules/__init__.py +0 -0
  24. sootool/modules/accounting/README.md +37 -0
  25. sootool/modules/accounting/__init__.py +25 -0
  26. sootool/modules/accounting/bookkeeping.py +52 -0
  27. sootool/modules/accounting/cashflow.py +99 -0
  28. sootool/modules/accounting/depreciation.py +277 -0
  29. sootool/modules/accounting/dupont.py +151 -0
  30. sootool/modules/accounting/income_statement.py +100 -0
  31. sootool/modules/accounting/ratios.py +110 -0
  32. sootool/modules/accounting/vat.py +137 -0
  33. sootool/modules/crypto/README.md +59 -0
  34. sootool/modules/crypto/__init__.py +9 -0
  35. sootool/modules/crypto/advanced.py +255 -0
  36. sootool/modules/crypto/arithmetic.py +167 -0
  37. sootool/modules/crypto/hash_ops.py +56 -0
  38. sootool/modules/crypto/primes.py +125 -0
  39. sootool/modules/datetime_/README.md +42 -0
  40. sootool/modules/datetime_/__init__.py +16 -0
  41. sootool/modules/datetime_/age.py +136 -0
  42. sootool/modules/datetime_/business_days.py +145 -0
  43. sootool/modules/datetime_/day_count.py +138 -0
  44. sootool/modules/datetime_/fiscal.py +264 -0
  45. sootool/modules/datetime_/lunar.py +440 -0
  46. sootool/modules/datetime_/timezone_ops.py +83 -0
  47. sootool/modules/engineering/README.md +93 -0
  48. sootool/modules/engineering/__init__.py +91 -0
  49. sootool/modules/engineering/control.py +325 -0
  50. sootool/modules/engineering/electrical.py +316 -0
  51. sootool/modules/engineering/electrical_ac.py +830 -0
  52. sootool/modules/engineering/equivalent_circuit.py +143 -0
  53. sootool/modules/engineering/fluid.py +555 -0
  54. sootool/modules/engineering/gear_bearing.py +230 -0
  55. sootool/modules/engineering/materials.py +264 -0
  56. sootool/modules/engineering/mechanical.py +313 -0
  57. sootool/modules/engineering/reliability.py +234 -0
  58. sootool/modules/engineering/si_prefix.py +106 -0
  59. sootool/modules/engineering/structural.py +398 -0
  60. sootool/modules/engineering/thermal.py +306 -0
  61. sootool/modules/finance/README.md +76 -0
  62. sootool/modules/finance/__init__.py +35 -0
  63. sootool/modules/finance/bond.py +261 -0
  64. sootool/modules/finance/derivatives.py +259 -0
  65. sootool/modules/finance/loan.py +214 -0
  66. sootool/modules/finance/metrics.py +274 -0
  67. sootool/modules/finance/option.py +191 -0
  68. sootool/modules/finance/risk.py +318 -0
  69. sootool/modules/finance/tvm.py +158 -0
  70. sootool/modules/geometry/README.md +69 -0
  71. sootool/modules/geometry/__init__.py +9 -0
  72. sootool/modules/geometry/area.py +183 -0
  73. sootool/modules/geometry/distance.py +102 -0
  74. sootool/modules/geometry/matrix_ops.py +233 -0
  75. sootool/modules/geometry/vector_ops.py +148 -0
  76. sootool/modules/geometry/volume.py +138 -0
  77. sootool/modules/math/__init__.py +22 -0
  78. sootool/modules/math/differentiation.py +116 -0
  79. sootool/modules/math/fft.py +123 -0
  80. sootool/modules/math/integration.py +147 -0
  81. sootool/modules/math/interpolation.py +144 -0
  82. sootool/modules/math/polynomial.py +114 -0
  83. sootool/modules/medical/README.md +84 -0
  84. sootool/modules/medical/__init__.py +16 -0
  85. sootool/modules/medical/body.py +172 -0
  86. sootool/modules/medical/dose.py +92 -0
  87. sootool/modules/medical/egfr.py +150 -0
  88. sootool/modules/medical/pregnancy.py +115 -0
  89. sootool/modules/medical/qt_correction.py +179 -0
  90. sootool/modules/medical/risk_scores.py +260 -0
  91. sootool/modules/payroll/__init__.py +9 -0
  92. sootool/modules/payroll/kr_salary.py +184 -0
  93. sootool/modules/pm/__init__.py +14 -0
  94. sootool/modules/pm/cpm.py +169 -0
  95. sootool/modules/pm/earned_schedule.py +150 -0
  96. sootool/modules/pm/evm.py +115 -0
  97. sootool/modules/pm/monte_carlo.py +153 -0
  98. sootool/modules/pm/pert.py +106 -0
  99. sootool/modules/probability/__init__.py +16 -0
  100. sootool/modules/probability/bayes.py +78 -0
  101. sootool/modules/probability/combinatorics.py +140 -0
  102. sootool/modules/probability/distributions.py +806 -0
  103. sootool/modules/probability/expected.py +91 -0
  104. sootool/modules/realestate/README.md +89 -0
  105. sootool/modules/realestate/__init__.py +23 -0
  106. sootool/modules/realestate/acquisition_tax.py +150 -0
  107. sootool/modules/realestate/kr_comprehensive.py +129 -0
  108. sootool/modules/realestate/kr_property_tax.py +106 -0
  109. sootool/modules/realestate/ratios.py +250 -0
  110. sootool/modules/realestate/rental_yield.py +105 -0
  111. sootool/modules/realestate/transfer_tax.py +63 -0
  112. sootool/modules/science/__init__.py +14 -0
  113. sootool/modules/science/chemistry.py +312 -0
  114. sootool/modules/science/electrochemistry.py +188 -0
  115. sootool/modules/science/optics.py +282 -0
  116. sootool/modules/science/periodic_table.py +53 -0
  117. sootool/modules/science/physics.py +102 -0
  118. sootool/modules/science/thermo.py +149 -0
  119. sootool/modules/stats/README.md +41 -0
  120. sootool/modules/stats/__init__.py +31 -0
  121. sootool/modules/stats/anova.py +129 -0
  122. sootool/modules/stats/bootstrap.py +114 -0
  123. sootool/modules/stats/ci.py +87 -0
  124. sootool/modules/stats/descriptive.py +85 -0
  125. sootool/modules/stats/effect_size.py +153 -0
  126. sootool/modules/stats/inference.py +283 -0
  127. sootool/modules/stats/nonparametric.py +180 -0
  128. sootool/modules/stats/regression.py +118 -0
  129. sootool/modules/tax/README.md +41 -0
  130. sootool/modules/tax/__init__.py +25 -0
  131. sootool/modules/tax/capital_gains.py +146 -0
  132. sootool/modules/tax/kr_corporate.py +137 -0
  133. sootool/modules/tax/kr_gift.py +118 -0
  134. sootool/modules/tax/kr_income.py +87 -0
  135. sootool/modules/tax/kr_inheritance.py +140 -0
  136. sootool/modules/tax/kr_withholding.py +180 -0
  137. sootool/modules/tax/progressive.py +205 -0
  138. sootool/modules/units/README.md +57 -0
  139. sootool/modules/units/__init__.py +13 -0
  140. sootool/modules/units/convert.py +69 -0
  141. sootool/modules/units/currency.py +235 -0
  142. sootool/modules/units/extended.py +269 -0
  143. sootool/modules/units/temperature.py +120 -0
  144. sootool/observability/__init__.py +1 -0
  145. sootool/observability/health.py +28 -0
  146. sootool/observability/log_format.py +36 -0
  147. sootool/policies/__init__.py +161 -0
  148. sootool/policies/payroll/__init__.py +0 -0
  149. sootool/policies/payroll/kr_4insurance_2026.yaml +43 -0
  150. sootool/policies/realestate/__init__.py +0 -0
  151. sootool/policies/realestate/kr_acquisition_2026.yaml +24 -0
  152. sootool/policies/realestate/kr_comprehensive_2026.yaml +45 -0
  153. sootool/policies/realestate/kr_dsr_ltv_2026.yaml +14 -0
  154. sootool/policies/realestate/kr_property_tax_2026.yaml +22 -0
  155. sootool/policies/tax/__init__.py +0 -0
  156. sootool/policies/tax/kr_capital_gains_2026.yaml +100 -0
  157. sootool/policies/tax/kr_corporate_2026.yaml +29 -0
  158. sootool/policies/tax/kr_gift_2026.yaml +25 -0
  159. sootool/policies/tax/kr_income_2026.yaml +24 -0
  160. sootool/policies/tax/kr_inheritance_2026.yaml +23 -0
  161. sootool/policies/tax/kr_withholding_2026.yaml +50 -0
  162. sootool/policy_mgmt/__init__.py +10 -0
  163. sootool/policy_mgmt/audit.py +106 -0
  164. sootool/policy_mgmt/diff.py +144 -0
  165. sootool/policy_mgmt/drafts.py +154 -0
  166. sootool/policy_mgmt/loader.py +201 -0
  167. sootool/policy_mgmt/paths.py +79 -0
  168. sootool/policy_mgmt/schemas.py +177 -0
  169. sootool/policy_mgmt/signatures.py +69 -0
  170. sootool/policy_mgmt/tools.py +577 -0
  171. sootool/policy_mgmt/trace_ext.py +74 -0
  172. sootool/policy_mgmt/validators.py +353 -0
  173. sootool/server.py +310 -0
  174. sootool/skill_guide/__init__.py +8 -0
  175. sootool/skill_guide/anti_patterns.py +97 -0
  176. sootool/skill_guide/examples.py +187 -0
  177. sootool/skill_guide/guide.py +67 -0
  178. sootool/skill_guide/hints.py +142 -0
  179. sootool/skill_guide/locale.py +75 -0
  180. sootool/skill_guide/playbooks.py +395 -0
  181. sootool/skill_guide/session_state.py +143 -0
  182. sootool/skill_guide/triggers.py +297 -0
  183. sootool/transports/__init__.py +1 -0
  184. sootool/transports/base.py +8 -0
  185. sootool/transports/http.py +98 -0
  186. sootool/transports/sse_legacy.py +127 -0
  187. sootool/transports/stdio.py +14 -0
  188. sootool/transports/unix.py +206 -0
  189. sootool/transports/websocket.py +253 -0
  190. sootool-0.1.1.dist-info/METADATA +238 -0
  191. sootool-0.1.1.dist-info/RECORD +193 -0
  192. sootool-0.1.1.dist-info/WHEEL +4 -0
  193. sootool-0.1.1.dist-info/entry_points.txt +3 -0
sootool/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version as _v
3
+
4
+ try:
5
+ __version__ = _v("sootool")
6
+ except PackageNotFoundError:
7
+ __version__ = "0.0.0+unknown"
8
+
9
+
10
+ def main() -> None:
11
+ print("Hello from sootool!")
sootool/__main__.py ADDED
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+ from sootool.observability.log_format import JsonFormatter
10
+ from sootool.server import _load_modules, build_server
11
+
12
+ _VALID_TRANSPORTS = {"stdio", "http", "sse-legacy", "websocket", "unix"}
13
+
14
+
15
+ def _configure_logging(log_format: str, log_level: str) -> None:
16
+ handler = logging.StreamHandler(sys.stderr)
17
+ if log_format == "json":
18
+ handler.setFormatter(JsonFormatter())
19
+ else:
20
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
21
+ root = logging.getLogger()
22
+ root.handlers.clear()
23
+ root.addHandler(handler)
24
+ root.setLevel(getattr(logging, log_level.upper(), logging.INFO))
25
+
26
+
27
+ def _parse_transports(raw: str, args: argparse.Namespace | None = None) -> list[str]:
28
+ if raw.strip() == "all":
29
+ # "all" activates stdio + http + opt-in transports that are enabled
30
+ base = ["stdio", "http"]
31
+ if args is not None:
32
+ if getattr(args, "enable_sse_legacy", False) or os.environ.get("SOOTOOL_ENABLE_SSE_LEGACY"):
33
+ base.append("sse-legacy")
34
+ if getattr(args, "enable_websocket", False) or os.environ.get("SOOTOOL_ENABLE_WEBSOCKET"):
35
+ base.append("websocket")
36
+ socket_path = getattr(args, "socket", None) or os.environ.get("SOOTOOL_SOCKET_PATH")
37
+ if socket_path:
38
+ base.append("unix")
39
+ return base
40
+ parts = [t.strip() for t in raw.split(",") if t.strip()]
41
+ unknown = set(parts) - _VALID_TRANSPORTS
42
+ if unknown:
43
+ raise argparse.ArgumentTypeError(
44
+ f"unknown transport(s): {', '.join(sorted(unknown))}. "
45
+ f"valid choices: {', '.join(sorted(_VALID_TRANSPORTS))}"
46
+ )
47
+ return parts
48
+
49
+
50
+ def _validate_security(
51
+ host: str,
52
+ auth_token: str | None,
53
+ transports: list[str],
54
+ ) -> None:
55
+ effective_token = auth_token or os.environ.get("SOOTOOL_AUTH_TOKEN")
56
+ network_transports = {"http", "sse-legacy", "websocket"}
57
+ if host != "127.0.0.1" and not effective_token:
58
+ if any(t in transports for t in network_transports):
59
+ sys.exit(
60
+ "ERROR: --host is set to a non-loopback address but no authentication token is "
61
+ "configured.\n"
62
+ "Set SOOTOOL_AUTH_TOKEN or pass --auth-token <token> to enable bearer auth "
63
+ "before exposing the server externally."
64
+ )
65
+
66
+
67
+ def _build_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog="sootool",
70
+ description="SooTool MCP server",
71
+ )
72
+ parser.add_argument(
73
+ "--transport",
74
+ default="stdio",
75
+ help=(
76
+ "Transport(s) to activate. Comma-separated or 'all'. "
77
+ "Valid: stdio, http, sse-legacy, websocket, unix. Default: stdio."
78
+ ),
79
+ )
80
+ parser.add_argument("--host", default="127.0.0.1", help="Network bind address (default 127.0.0.1)")
81
+ parser.add_argument("--http-port", type=int, default=10535, dest="http_port", help="Streamable HTTP port")
82
+ parser.add_argument("--sse-port", type=int, default=10536, dest="sse_port", help="SSE legacy port (default 10536)")
83
+ parser.add_argument("--ws-port", type=int, default=10537, dest="ws_port", help="WebSocket port (default 10537)")
84
+ parser.add_argument("--auth-token", default=None, dest="auth_token", help="Bearer token for network transports")
85
+ parser.add_argument(
86
+ "--cors-origin",
87
+ action="append",
88
+ default=[],
89
+ dest="cors_origins",
90
+ metavar="ORIGIN",
91
+ help="Allowed CORS origin (repeatable). Falls back to SOOTOOL_CORS_ORIGINS env.",
92
+ )
93
+ # SSE legacy
94
+ parser.add_argument(
95
+ "--enable-sse-legacy",
96
+ action="store_true",
97
+ default=False,
98
+ dest="enable_sse_legacy",
99
+ help="Enable HTTP+SSE legacy transport (MCP 2024-11). Also: SOOTOOL_ENABLE_SSE_LEGACY=1",
100
+ )
101
+ # WebSocket
102
+ parser.add_argument(
103
+ "--enable-websocket",
104
+ action="store_true",
105
+ default=False,
106
+ dest="enable_websocket",
107
+ help="Enable WebSocket transport. Also: SOOTOOL_ENABLE_WEBSOCKET=1",
108
+ )
109
+ # Unix socket
110
+ parser.add_argument(
111
+ "--socket",
112
+ default=None,
113
+ dest="socket",
114
+ metavar="PATH",
115
+ help="Unix domain socket path. Also: SOOTOOL_SOCKET_PATH",
116
+ )
117
+ parser.add_argument(
118
+ "--socket-mode",
119
+ default="0600",
120
+ dest="socket_mode",
121
+ metavar="MODE",
122
+ help="Unix socket file permissions (octal, default 0600)",
123
+ )
124
+ parser.add_argument(
125
+ "--force-socket",
126
+ action="store_true",
127
+ default=False,
128
+ dest="force_socket",
129
+ help="Remove stale Unix socket file on startup instead of refusing to start",
130
+ )
131
+ parser.add_argument("--log-format", choices=["json", "text"], default="json", dest="log_format")
132
+ parser.add_argument(
133
+ "--log-level",
134
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
135
+ default="INFO",
136
+ dest="log_level",
137
+ )
138
+ return parser
139
+
140
+
141
+ async def _run(transports: list[str], args: argparse.Namespace) -> None:
142
+ server = build_server()
143
+
144
+ from sootool.transports.http import HttpTransport
145
+ from sootool.transports.sse_legacy import SseLegacyTransport
146
+ from sootool.transports.stdio import StdioTransport
147
+ from sootool.transports.unix import UnixTransport
148
+ from sootool.transports.websocket import WebSocketTransport
149
+
150
+ tasks = []
151
+
152
+ if "stdio" in transports and any(t in transports for t in ("http", "sse-legacy", "websocket")):
153
+ logging.getLogger("sootool").warning(
154
+ "Running stdio and network transport(s) simultaneously. When managed by "
155
+ "systemd/supervisor, ensure stdin/stdout are not shared with HTTP processes."
156
+ )
157
+
158
+ if "stdio" in transports:
159
+ tasks.append(StdioTransport(server).start_async())
160
+
161
+ if "http" in transports:
162
+ tasks.append(
163
+ HttpTransport(
164
+ server=server,
165
+ host=args.host,
166
+ port=args.http_port,
167
+ auth_token=args.auth_token,
168
+ cors_origins=args.cors_origins,
169
+ log_level=args.log_level.lower(),
170
+ ).start_async()
171
+ )
172
+
173
+ if "sse-legacy" in transports:
174
+ tasks.append(
175
+ SseLegacyTransport(
176
+ server=server,
177
+ host=args.host,
178
+ port=args.sse_port,
179
+ auth_token=args.auth_token,
180
+ cors_origins=args.cors_origins,
181
+ log_level=args.log_level.lower(),
182
+ ).start_async()
183
+ )
184
+
185
+ if "websocket" in transports:
186
+ tasks.append(
187
+ WebSocketTransport(
188
+ server=server,
189
+ host=args.host,
190
+ port=args.ws_port,
191
+ auth_token=args.auth_token,
192
+ cors_origins=args.cors_origins,
193
+ log_level=args.log_level.lower(),
194
+ ).start_async()
195
+ )
196
+
197
+ if "unix" in transports:
198
+ socket_mode_str = getattr(args, "socket_mode", "0600")
199
+ try:
200
+ socket_mode = int(socket_mode_str, 8)
201
+ except ValueError:
202
+ sys.exit(f"ERROR: invalid --socket-mode value: {socket_mode_str!r} (expected octal)")
203
+ tasks.append(
204
+ UnixTransport(
205
+ server=server,
206
+ socket_path=args.socket,
207
+ socket_mode=socket_mode,
208
+ force=args.force_socket,
209
+ ).start_async()
210
+ )
211
+
212
+ if not tasks:
213
+ sys.exit("No transports selected.")
214
+
215
+ await asyncio.gather(*tasks)
216
+
217
+
218
+ def main() -> None:
219
+ parser = _build_parser()
220
+ args = parser.parse_args()
221
+
222
+ _configure_logging(args.log_format, args.log_level)
223
+
224
+ try:
225
+ transports = _parse_transports(args.transport, args)
226
+ except argparse.ArgumentTypeError as exc:
227
+ parser.error(str(exc))
228
+
229
+ # Opt-in flags augment explicit transport list
230
+ if args.enable_sse_legacy and "sse-legacy" not in transports:
231
+ transports.append("sse-legacy")
232
+ if args.enable_websocket and "websocket" not in transports:
233
+ transports.append("websocket")
234
+ unix_path = args.socket or os.environ.get("SOOTOOL_SOCKET_PATH")
235
+ if unix_path and "unix" not in transports and args.transport.strip() != "all":
236
+ transports.append("unix")
237
+
238
+ _validate_security(args.host, args.auth_token, transports)
239
+
240
+ _load_modules()
241
+ asyncio.run(_run(transports, args))
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
File without changes
sootool/core/audit.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from decimal import Decimal
5
+ from typing import Any
6
+
7
+
8
+ def _normalize(v: Any) -> Any:
9
+ if isinstance(v, Decimal):
10
+ return str(v)
11
+ if isinstance(v, (list, tuple)):
12
+ return [_normalize(x) for x in v]
13
+ if isinstance(v, dict):
14
+ return {k: _normalize(x) for k, x in v.items()}
15
+ return v
16
+
17
+
18
+ @dataclass
19
+ class CalcTrace:
20
+ tool: str
21
+ formula: str = ""
22
+ inputs: dict[str, Any] = field(default_factory=dict)
23
+ steps: list[dict[str, Any]] = field(default_factory=list)
24
+ output_value: Any = None
25
+
26
+ def input(self, name: str, value: Any) -> None:
27
+ self.inputs[name] = _normalize(value)
28
+
29
+ def step(self, label: str, value: Any) -> None:
30
+ self.steps.append({"label": label, "value": _normalize(value)})
31
+
32
+ def output(self, value: Any) -> None:
33
+ self.output_value = _normalize(value)
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "tool": self.tool,
38
+ "formula": self.formula,
39
+ "inputs": self.inputs,
40
+ "steps": self.steps,
41
+ "output": self.output_value,
42
+ }
sootool/core/batch.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from concurrent.futures import Future, ThreadPoolExecutor, as_completed
5
+ from concurrent.futures import TimeoutError as FuturesTimeout
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from sootool.core.errors import SooToolError
10
+ from sootool.core.registry import ToolRegistry
11
+
12
+
13
+ class BatchLimitError(SooToolError):
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class BatchExecutor:
19
+ registry: ToolRegistry
20
+ max_items: int = 500
21
+ item_timeout_s: float = 10.0
22
+ batch_timeout_s: float = 60.0
23
+ max_workers: int = 16
24
+ deterministic: bool = True
25
+
26
+ def run(self, items: list[dict[str, Any]]) -> dict[str, Any]:
27
+ if len(items) > self.max_items:
28
+ raise BatchLimitError(f"배치 항목 수 {len(items)} > 한도 {self.max_items}")
29
+ ids = [it["id"] for it in items]
30
+ if len(set(ids)) != len(ids):
31
+ raise ValueError("배치 항목 id 중복")
32
+
33
+ started = time.monotonic()
34
+ workers = min(self.max_workers, max(1, len(items)))
35
+ results: dict[str, dict[str, Any]] = {}
36
+
37
+ with ThreadPoolExecutor(max_workers=workers) as pool:
38
+ futures: dict[Future[Any], str] = {}
39
+ for it in items:
40
+ fut = pool.submit(self.registry.invoke, it["tool"], **it.get("args", {}))
41
+ futures[fut] = it["id"]
42
+
43
+ if self.deterministic:
44
+ # Collect by future mapped to item id, then order by input order
45
+ id_to_future: dict[str, Future[Any]] = {v: k for k, v in futures.items()}
46
+ for item_id, fut in id_to_future.items():
47
+ t0 = time.monotonic()
48
+ try:
49
+ res = fut.result(timeout=self.item_timeout_s)
50
+ results[item_id] = {
51
+ "id": item_id,
52
+ "status": "ok",
53
+ "result": res,
54
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
55
+ }
56
+ except FuturesTimeout:
57
+ fut.cancel()
58
+ results[item_id] = {
59
+ "id": item_id,
60
+ "status": "timeout",
61
+ "error": {"type": "TimeoutError", "message": f"item_timeout_s={self.item_timeout_s}"},
62
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
63
+ }
64
+ except Exception as e:
65
+ results[item_id] = {
66
+ "id": item_id,
67
+ "status": "error",
68
+ "error": {"type": type(e).__name__, "message": str(e)},
69
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
70
+ }
71
+ else:
72
+ # Non-deterministic: collect by completion order
73
+ for fut in as_completed(futures):
74
+ item_id = futures[fut]
75
+ t0 = time.monotonic()
76
+ try:
77
+ res = fut.result(timeout=0)
78
+ results[item_id] = {
79
+ "id": item_id,
80
+ "status": "ok",
81
+ "result": res,
82
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
83
+ }
84
+ except FuturesTimeout:
85
+ results[item_id] = {
86
+ "id": item_id,
87
+ "status": "timeout",
88
+ "error": {"type": "TimeoutError", "message": f"item_timeout_s={self.item_timeout_s}"},
89
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
90
+ }
91
+ except Exception as e:
92
+ results[item_id] = {
93
+ "id": item_id,
94
+ "status": "error",
95
+ "error": {"type": type(e).__name__, "message": str(e)},
96
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
97
+ }
98
+
99
+ total_ms = int((time.monotonic() - started) * 1000)
100
+
101
+ if self.deterministic:
102
+ ordered = [results[it["id"]] for it in items]
103
+ else:
104
+ # Ordered by completion time (already in insertion order via dict)
105
+ ordered = list(results.values())
106
+
107
+ count_ok = sum(1 for r in ordered if r["status"] == "ok")
108
+ count_error = sum(1 for r in ordered if r["status"] == "error")
109
+ count_timeout = sum(1 for r in ordered if r["status"] == "timeout")
110
+
111
+ if count_ok == len(ordered):
112
+ status = "all_ok"
113
+ elif count_ok == 0:
114
+ status = "all_failed"
115
+ else:
116
+ status = "partial"
117
+
118
+ response: dict[str, Any] = {
119
+ "status": status,
120
+ "results": ordered,
121
+ "count_ok": count_ok,
122
+ "count_error": count_error,
123
+ "count_timeout": count_timeout,
124
+ "total_time_ms": total_ms,
125
+ "parallelism": workers,
126
+ }
127
+ if not self.deterministic:
128
+ response["non_deterministic"] = True
129
+ return response