tylor-mcp 1.0.0

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 (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
@@ -0,0 +1,117 @@
1
+ """
2
+ server/tools/router.py — model routing with transparent overflow fallback.
3
+
4
+ Routes normal message calls through the primary Claude client. If the primary
5
+ route hits a rate limit, retries the identical request through Claude Platform
6
+ on AWS. context_length_exceeded is not retried — the identical payload would
7
+ fail on the platform route too.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from typing import Any, Mapping
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ from mcp.shared.exceptions import McpError
18
+ from mcp.types import ErrorData, INTERNAL_ERROR
19
+
20
+ OVERFLOW_ERROR_TYPES = {"rate_limit_error"}
21
+ AWS_PLATFORM_BASE_URL_TEMPLATE = "https://aws-external-anthropic.{region}.api.aws"
22
+
23
+
24
+ def _error_type(exc: BaseException) -> str:
25
+ """Extract Anthropic-style error type from SDK or API exceptions."""
26
+ direct_type = getattr(exc, "type", None)
27
+ if isinstance(direct_type, str):
28
+ return direct_type
29
+
30
+ error = getattr(exc, "error", None)
31
+ nested_type = getattr(error, "type", None)
32
+ if isinstance(nested_type, str):
33
+ return nested_type
34
+ if isinstance(error, Mapping):
35
+ mapped_type = error.get("type")
36
+ if isinstance(mapped_type, str):
37
+ return mapped_type
38
+
39
+ message = str(exc).lower()
40
+ for error_type in OVERFLOW_ERROR_TYPES:
41
+ if error_type in message:
42
+ return error_type
43
+ return ""
44
+
45
+
46
+ def is_overflow_error(exc: BaseException) -> bool:
47
+ """Return True only for errors that should trigger Platform on AWS fallback."""
48
+ return _error_type(exc) in OVERFLOW_ERROR_TYPES
49
+
50
+
51
+ def _internal_error(message: str) -> McpError:
52
+ return McpError(ErrorData(code=INTERNAL_ERROR, message=message))
53
+
54
+
55
+ @dataclass
56
+ class ModelRouter:
57
+ """Message router with primary route and Claude Platform on AWS fallback."""
58
+
59
+ primary_client: Any
60
+ platform_client: Any | None = None
61
+
62
+ def create_message(self, **request: Any) -> Any:
63
+ """
64
+ Create a Claude message through primary route, falling back only for
65
+ rate-limit errors. The fallback receives the identical request kwargs.
66
+ """
67
+ try:
68
+ return self.primary_client.messages.create(**request)
69
+ except Exception as primary_exc:
70
+ if not is_overflow_error(primary_exc):
71
+ raise
72
+ if self.platform_client is None:
73
+ from server.config import config
74
+ if not config.get("platform_key"):
75
+ logger.warning(
76
+ "platform_client is null — ANTHROPIC_PLATFORM_AWS_API_KEY not configured; "
77
+ "overflow fallback unavailable"
78
+ )
79
+ raise _internal_error("All model routes exhausted") from primary_exc
80
+ try:
81
+ platform = self.platform_client or build_platform_client()
82
+ self.platform_client = platform
83
+ return platform.messages.create(**request)
84
+ except Exception as platform_exc:
85
+ raise _internal_error("All model routes exhausted") from platform_exc
86
+
87
+
88
+ def build_platform_base_url(region: str) -> str:
89
+ """Build the regional Claude Platform on AWS endpoint."""
90
+ return AWS_PLATFORM_BASE_URL_TEMPLATE.format(region=region)
91
+
92
+
93
+ def build_platform_client() -> Any:
94
+ """Construct an Anthropic SDK client for Claude Platform on AWS."""
95
+ from anthropic import Anthropic
96
+ from server.config import config
97
+
98
+ platform_key = config.get("platform_key")
99
+ if not platform_key:
100
+ raise RuntimeError("ANTHROPIC_PLATFORM_AWS_API_KEY not configured")
101
+
102
+ base_url = config.get("platform_base_url") or build_platform_base_url(
103
+ config.get("bedrock_region", "us-east-1")
104
+ )
105
+ workspace_id = config.get("platform_workspace_id")
106
+ headers = {"anthropic-workspace-id": workspace_id} if workspace_id else None
107
+
108
+ return Anthropic(
109
+ api_key=platform_key,
110
+ base_url=base_url,
111
+ default_headers=headers,
112
+ )
113
+
114
+
115
+ def create_message(primary_client: Any, **request: Any) -> Any:
116
+ """Convenience wrapper for call sites that do not need a router instance."""
117
+ return ModelRouter(primary_client=primary_client).create_message(**request)
@@ -0,0 +1,230 @@
1
+ """Skill package installer for the /add-skill command."""
2
+ from __future__ import annotations
3
+ import json
4
+ import re
5
+ import shutil
6
+ import argparse
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from mcp.shared.exceptions import McpError
11
+ from mcp.server.fastmcp.exceptions import ToolError
12
+ from mcp.types import ErrorData, INVALID_PARAMS
13
+
14
+ from ._mcp import mcp
15
+
16
+ PLUGIN_DIR = Path(__file__).resolve().parents[2]
17
+ DEFAULT_SKILLS_DIR = PLUGIN_DIR / "skills"
18
+ DEFAULT_REGISTRY_PATH = PLUGIN_DIR / "registry.json"
19
+
20
+ _STOPWORDS = {
21
+ "and",
22
+ "for",
23
+ "the",
24
+ "this",
25
+ "that",
26
+ "use",
27
+ "user",
28
+ "when",
29
+ "wants",
30
+ "with",
31
+ }
32
+
33
+
34
+ def _invalid_params(message: str) -> McpError:
35
+ return McpError(ErrorData(code=INVALID_PARAMS, message=message))
36
+
37
+
38
+ def _now_date() -> str:
39
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
40
+
41
+
42
+ def _read_registry(path: Path) -> dict:
43
+ if not path.exists():
44
+ return {"version": "1.0", "skills": []}
45
+ return json.loads(path.read_text(encoding="utf-8"))
46
+
47
+
48
+ def _write_registry(path: Path, data: dict) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ tmp = path.with_suffix(".tmp")
51
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
52
+ tmp.replace(path)
53
+
54
+
55
+ def _parse_frontmatter_value(value: str):
56
+ value = value.strip()
57
+ if value.startswith("[") and value.endswith("]"):
58
+ try:
59
+ return json.loads(value)
60
+ except json.JSONDecodeError:
61
+ pass
62
+ return value.strip("'\"")
63
+
64
+
65
+ def _frontmatter(text: str) -> dict:
66
+ if not text.startswith("---\n"):
67
+ return {}
68
+ end = text.find("\n---", 4)
69
+ if end == -1:
70
+ return {}
71
+ data = {}
72
+ for line in text[4:end].splitlines():
73
+ if ":" not in line:
74
+ continue
75
+ key, value = line.split(":", 1)
76
+ data[key.strip()] = _parse_frontmatter_value(value)
77
+ return data
78
+
79
+
80
+ def _skill_name(source_path: Path, metadata: dict, explicit_name: str | None) -> str:
81
+ raw = explicit_name or metadata.get("name") or source_path.name
82
+ name = raw.strip().lower().replace(" ", "-").replace("_", "-")
83
+ if not re.match(r"^[a-z0-9][a-z0-9-]*$", name):
84
+ raise _invalid_params(f"Invalid skill name: {raw}")
85
+ return name
86
+
87
+
88
+ def _trigger_description(text: str, metadata: dict) -> str:
89
+ description = metadata.get("description", "").strip()
90
+ if description:
91
+ return description
92
+ for line in text.splitlines():
93
+ line = line.strip()
94
+ if line.lower().startswith("use when"):
95
+ return line
96
+ return "No trigger description provided."
97
+
98
+
99
+ def _keywords(name: str, trigger: str) -> list[str]:
100
+ words = re.findall(r"[a-z0-9]+", f"{name} {trigger}".lower())
101
+ seen = set()
102
+ keywords = []
103
+ for word in words:
104
+ if len(word) < 3 or word in _STOPWORDS or word in seen:
105
+ continue
106
+ seen.add(word)
107
+ keywords.append(word)
108
+ return keywords
109
+
110
+
111
+ def _tool_count(text: str) -> int:
112
+ tool_refs = set(re.findall(r"`([a-zA-Z_][a-zA-Z0-9_]+)\(", text))
113
+ return len(tool_refs)
114
+
115
+
116
+ def _copy_skill(source_path: Path, target_path: Path, overwrite: bool) -> None:
117
+ if target_path.exists():
118
+ if not overwrite:
119
+ raise _invalid_params(
120
+ f"Skill '{target_path.name}' already exists; rerun with overwrite=True to replace it."
121
+ )
122
+ shutil.rmtree(target_path)
123
+ shutil.copytree(source_path, target_path)
124
+
125
+
126
+ def install_skill(
127
+ source_path: str | Path,
128
+ name: str | None = None,
129
+ overwrite: bool = False,
130
+ skills_dir: str | Path = DEFAULT_SKILLS_DIR,
131
+ registry_path: str | Path = DEFAULT_REGISTRY_PATH,
132
+ ) -> dict:
133
+ """Copy a skill package and upsert its generated registry entry."""
134
+ source = Path(source_path).expanduser().resolve()
135
+ skill_file = source / "SKILL.md"
136
+ if not skill_file.exists():
137
+ raise _invalid_params(f"SKILL.md not found in {source}")
138
+
139
+ text = skill_file.read_text(encoding="utf-8")
140
+ metadata = _frontmatter(text)
141
+ skill_name = _skill_name(source, metadata, name)
142
+ trigger = _trigger_description(text, metadata)
143
+ target = Path(skills_dir) / skill_name
144
+ registry = _read_registry(Path(registry_path))
145
+ skills = list(registry.get("skills", []))
146
+ exists = any(entry.get("name") == skill_name for entry in skills)
147
+
148
+ if exists and not overwrite:
149
+ raise _invalid_params(
150
+ f"Skill '{skill_name}' already exists in registry.json; rerun with overwrite=True to replace it."
151
+ )
152
+
153
+ _copy_skill(source, target, overwrite=overwrite)
154
+
155
+ module = metadata.get("module")
156
+ tools = metadata.get("tools")
157
+ if isinstance(tools, str):
158
+ tools = [tools]
159
+ if tools is not None and not isinstance(tools, list):
160
+ raise _invalid_params("Invalid tools metadata in SKILL.md; use a comma-separated list or JSON array.")
161
+
162
+ entry = {
163
+ "name": skill_name,
164
+ "trigger": trigger,
165
+ "trigger_description": trigger,
166
+ "keywords": _keywords(skill_name, trigger),
167
+ "tool_count": _tool_count(text),
168
+ "installed_date": _now_date(),
169
+ "source_path": str(source),
170
+ }
171
+ if module:
172
+ entry["module"] = str(module)
173
+ if tools:
174
+ entry["tools"] = [str(tool).strip() for tool in tools if str(tool).strip()]
175
+
176
+ registry["skills"] = [entry for entry in skills if entry.get("name") != skill_name]
177
+ registry["skills"].append(entry)
178
+ _write_registry(Path(registry_path), registry)
179
+
180
+ return {
181
+ "status": "installed",
182
+ "name": skill_name,
183
+ "installed_to": str(target),
184
+ "registry_path": str(registry_path),
185
+ }
186
+
187
+
188
+ @mcp.tool()
189
+ def add_skill(
190
+ source_path: str,
191
+ name: str | None = None,
192
+ overwrite: bool = False,
193
+ ) -> dict:
194
+ """
195
+ Install an agent101 skill package and update registry.json.
196
+
197
+ Args:
198
+ source_path: Local path to a skill package directory containing SKILL.md.
199
+ name: Optional skill name override.
200
+ overwrite: Replace an existing installed skill if present.
201
+ """
202
+ try:
203
+ return install_skill(
204
+ source_path=source_path,
205
+ name=name,
206
+ overwrite=overwrite,
207
+ )
208
+ except McpError:
209
+ raise
210
+ except Exception as exc:
211
+ raise ToolError(f"add_skill failed: {exc}") from exc
212
+
213
+
214
+ def main() -> None:
215
+ parser = argparse.ArgumentParser(description="Install an agent101 skill package.")
216
+ parser.add_argument("source_path")
217
+ parser.add_argument("--name")
218
+ parser.add_argument("--overwrite", action="store_true")
219
+ args = parser.parse_args()
220
+
221
+ result = install_skill(
222
+ source_path=args.source_path,
223
+ name=args.name,
224
+ overwrite=args.overwrite,
225
+ )
226
+ print(json.dumps(result, indent=2))
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
@@ -0,0 +1,168 @@
1
+ """
2
+ server/tools/summarizer.py — async thread summarization via Bedrock Opus.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ from typing import Any
10
+
11
+ import boto3
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DEFAULT_LAST_N_MESSAGES = 20
16
+ DEFAULT_SUMMARY_MAX_TOKENS = 1024
17
+ DEFAULT_BEDROCK_OPUS_MODEL = "us.anthropic.claude-opus-4-7-20251101-v1:0"
18
+
19
+
20
+ def _message_text(message: dict) -> str:
21
+ role = message.get("Role") or message.get("role") or "unknown"
22
+ content = message.get("Content") or message.get("content") or ""
23
+ return f"{role}: {content}"
24
+
25
+
26
+ def _last_messages(db: Any, thread_id: str, limit: int = DEFAULT_LAST_N_MESSAGES) -> list[dict]:
27
+ messages = db.query_thread(thread_id, f"THREAD#{thread_id}#MSG#")
28
+ messages.sort(key=lambda item: item.get("SK", ""))
29
+ return messages[-limit:]
30
+
31
+
32
+ def _raw_fallback_summary(messages: list[dict]) -> str:
33
+ if not messages:
34
+ return "No messages were available for fallback summary."
35
+ return "\n".join(_message_text(message) for message in messages)
36
+
37
+
38
+ def _extract_bedrock_text(response: dict) -> str:
39
+ body = response.get("body")
40
+ payload = body.read() if hasattr(body, "read") else body
41
+ if isinstance(payload, bytes):
42
+ payload = payload.decode("utf-8")
43
+ data = json.loads(payload)
44
+
45
+ content = data.get("content", [])
46
+ if isinstance(content, list):
47
+ parts = [
48
+ part.get("text", "")
49
+ for part in content
50
+ if isinstance(part, dict) and part.get("type") == "text"
51
+ ]
52
+ return "".join(parts).strip()
53
+ if isinstance(content, str):
54
+ return content.strip()
55
+ return ""
56
+
57
+
58
+ def _summary_prompt(messages: list[dict]) -> str:
59
+ transcript = _raw_fallback_summary(messages)
60
+ return (
61
+ "Summarize this development thread for future context restoration. "
62
+ "Keep durable decisions, changed files, unresolved risks, and next steps.\n\n"
63
+ f"{transcript}"
64
+ )
65
+
66
+
67
+ def _build_bedrock_client() -> Any:
68
+ from server.config import config
69
+
70
+ session_kwargs: dict[str, str] = {}
71
+ if config.get("aws_profile"):
72
+ session_kwargs["profile_name"] = config["aws_profile"]
73
+ session = boto3.Session(**session_kwargs)
74
+ return session.client(
75
+ "bedrock-runtime",
76
+ region_name=config.get("bedrock_region", "us-east-1"),
77
+ )
78
+
79
+
80
+ def _bedrock_model_id() -> str:
81
+ from server.config import config
82
+
83
+ return config.get("bedrock_opus_model") or DEFAULT_BEDROCK_OPUS_MODEL
84
+
85
+
86
+ def _mark_thread_killed(db: Any, thread_id: str) -> None:
87
+ meta = db.get_thread_meta(thread_id) or {}
88
+ attributes = dict(meta)
89
+ attributes["Status"] = "killed"
90
+ db.put_item(f"THREAD#{thread_id}#META", attributes)
91
+
92
+
93
+ def _write_summary(
94
+ db: Any,
95
+ thread_id: str,
96
+ summary: str,
97
+ summary_type: str,
98
+ error: str | None = None,
99
+ ) -> None:
100
+ attributes = {
101
+ "Summary": summary,
102
+ "SummaryType": summary_type,
103
+ }
104
+ if error:
105
+ attributes["Error"] = error
106
+ db.put_item(f"THREAD#{thread_id}#SUMMARY", attributes)
107
+
108
+
109
+ def _log_failure(db: Any, thread_id: str, error: str) -> None:
110
+ from server.tools.tylor import _now_iso
111
+
112
+ sk = f"THREAD#{thread_id}#MSG#{_now_iso()}#SUMMARY_FAILURE"
113
+ db.put_item(sk, {
114
+ "Role": "system",
115
+ "Content": f"kill_thread summarization failed: {error}",
116
+ })
117
+
118
+
119
+ async def summarize_thread(
120
+ thread_id: str,
121
+ db: Any,
122
+ bedrock_client: Any | None = None,
123
+ model_id: str | None = None,
124
+ last_n: int = DEFAULT_LAST_N_MESSAGES,
125
+ ) -> None:
126
+ """
127
+ Summarize a thread, persist the result, and mark the thread killed.
128
+ On Bedrock failure, stores raw last-N messages and logs the failure.
129
+ """
130
+ messages = _last_messages(db, thread_id, last_n)
131
+ bedrock = bedrock_client or _build_bedrock_client()
132
+ model = model_id or _bedrock_model_id()
133
+
134
+ try:
135
+ body = {
136
+ "anthropic_version": "bedrock-2023-05-31",
137
+ "max_tokens": DEFAULT_SUMMARY_MAX_TOKENS,
138
+ "messages": [
139
+ {
140
+ "role": "user",
141
+ "content": _summary_prompt(messages),
142
+ }
143
+ ],
144
+ }
145
+ response = await asyncio.to_thread(
146
+ bedrock.invoke_model,
147
+ modelId=model,
148
+ body=json.dumps(body),
149
+ contentType="application/json",
150
+ accept="application/json",
151
+ )
152
+ summary = _extract_bedrock_text(response)
153
+ if not summary:
154
+ raise RuntimeError("Bedrock returned an empty summary")
155
+ _write_summary(db, thread_id, summary, "bedrock_opus")
156
+ except Exception as exc:
157
+ error = str(exc)
158
+ logger.exception("Thread summarization failed for %s", thread_id)
159
+ _write_summary(
160
+ db,
161
+ thread_id,
162
+ _raw_fallback_summary(messages),
163
+ "raw_fallback",
164
+ error=error,
165
+ )
166
+ _log_failure(db, thread_id, error)
167
+ finally:
168
+ _mark_thread_killed(db, thread_id)
File without changes