openlcm 0.1.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 (51) hide show
  1. openlcm/__init__.py +80 -0
  2. openlcm/adapters/__init__.py +256 -0
  3. openlcm/adapters/anthropic.py +283 -0
  4. openlcm/adapters/autogen.py +342 -0
  5. openlcm/adapters/base.py +64 -0
  6. openlcm/adapters/crewai.py +144 -0
  7. openlcm/adapters/gemini.py +310 -0
  8. openlcm/adapters/google_adk.py +381 -0
  9. openlcm/adapters/haystack.py +272 -0
  10. openlcm/adapters/langchain.py +235 -0
  11. openlcm/adapters/langgraph.py +246 -0
  12. openlcm/adapters/llamaindex.py +234 -0
  13. openlcm/adapters/openai.py +241 -0
  14. openlcm/backends/__init__.py +14 -0
  15. openlcm/backends/anthropic.py +83 -0
  16. openlcm/backends/base.py +42 -0
  17. openlcm/backends/callable.py +131 -0
  18. openlcm/backends/litellm.py +98 -0
  19. openlcm/backends/openai.py +91 -0
  20. openlcm/cli/__init__.py +0 -0
  21. openlcm/cli/main.py +319 -0
  22. openlcm/core/__init__.py +1 -0
  23. openlcm/core/config.py +350 -0
  24. openlcm/core/dag.py +620 -0
  25. openlcm/core/db_bootstrap.py +453 -0
  26. openlcm/core/engine.py +1782 -0
  27. openlcm/core/escalation.py +298 -0
  28. openlcm/core/externalize.py +427 -0
  29. openlcm/core/extraction.py +232 -0
  30. openlcm/core/ingest_protection.py +1198 -0
  31. openlcm/core/lifecycle_state.py +572 -0
  32. openlcm/core/message_content.py +91 -0
  33. openlcm/core/message_patterns.py +116 -0
  34. openlcm/core/model_routing.py +104 -0
  35. openlcm/core/presets.py +294 -0
  36. openlcm/core/schemas.py +319 -0
  37. openlcm/core/search_query.py +283 -0
  38. openlcm/core/session_patterns.py +52 -0
  39. openlcm/core/store.py +1058 -0
  40. openlcm/core/tokens.py +61 -0
  41. openlcm/core/tools.py +1855 -0
  42. openlcm/viz/__init__.py +0 -0
  43. openlcm/viz/events.py +138 -0
  44. openlcm/viz/server.py +389 -0
  45. openlcm/viz/static/dashboard.js +967 -0
  46. openlcm/viz/static/index.html +294 -0
  47. openlcm/viz/static/styles.css +477 -0
  48. openlcm-0.1.0.dist-info/METADATA +226 -0
  49. openlcm-0.1.0.dist-info/RECORD +51 -0
  50. openlcm-0.1.0.dist-info/WHEEL +4 -0
  51. openlcm-0.1.0.dist-info/entry_points.txt +2 -0
openlcm/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ """OpenLCM — Framework-agnostic Lossless Context Management SDK.
2
+
3
+ One install, every provider::
4
+
5
+ pip install openlcm
6
+
7
+ Quick start — pass any LiteLLM model string::
8
+
9
+ from openlcm import LCMEngine
10
+
11
+ # Anthropic
12
+ engine = LCMEngine(model="anthropic/claude-haiku-4-5-20251001")
13
+
14
+ # Azure OpenAI
15
+ engine = LCMEngine(model="azure/gpt-4o")
16
+
17
+ # AWS Bedrock
18
+ engine = LCMEngine(model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0")
19
+
20
+ # Google Gemini
21
+ engine = LCMEngine(model="gemini/gemini-2.0-flash")
22
+
23
+ # Vertex AI
24
+ engine = LCMEngine(model="vertex_ai/gemini-pro")
25
+
26
+ # Ollama (local)
27
+ engine = LCMEngine(model="ollama/llama3.2", api_base="http://localhost:11434")
28
+
29
+ # WatsonX
30
+ engine = LCMEngine(model="watsonx/ibm/granite-13b-chat-v2")
31
+
32
+ # Custom OpenAI-compatible endpoint
33
+ engine = LCMEngine(model="openai/my-model", api_base="http://my-server/v1")
34
+
35
+ Bind a session and compress each turn::
36
+
37
+ engine.bind_session("session-abc", context_length=200_000)
38
+ compressed = await engine.compress(messages) # call before every LLM turn
39
+
40
+ Already using a framework LLM? Pass it directly — no separate model config needed::
41
+
42
+ from langchain_anthropic import ChatAnthropic
43
+ from openlcm.adapters.langgraph import LCMCheckpointer
44
+
45
+ llm = ChatAnthropic(model="claude-3-haiku-20240307") # your existing LLM
46
+ checkpointer = LCMCheckpointer(llm=llm) # reuses it for summarization
47
+
48
+ # Works the same way for CrewAI, AutoGen, Google ADK:
49
+ LCMStorage(llm=my_crewai_llm)
50
+ LCMContext(llm=my_autogen_client)
51
+ LCMSessionService(llm=my_gemini_model)
52
+
53
+ # Or pass any callable to LCMEngine directly:
54
+ engine = LCMEngine(summarize_fn=llm)
55
+ engine = LCMEngine(summarize_fn=lambda prompt, max_tokens: llm.invoke(prompt).content)
56
+
57
+ Live dashboard::
58
+
59
+ openlcm viz # http://localhost:7842
60
+
61
+ Advanced — bring your own backend (custom inference server, etc.)::
62
+
63
+ from openlcm.backends.base import SummaryBackend
64
+
65
+ class MyBackend(SummaryBackend):
66
+ async def summarize(self, prompt, max_tokens, model="", timeout=None):
67
+ ...
68
+
69
+ engine = LCMEngine(backend=MyBackend())
70
+
71
+ Full provider list: https://docs.litellm.ai/docs/providers
72
+ """
73
+
74
+ from .core.engine import LCMEngine
75
+ from .core.config import LCMConfig
76
+ from .backends.base import SummaryBackend
77
+ from .backends.callable import CallableBackend
78
+
79
+ __version__ = "0.1.0"
80
+ __all__ = ["LCMEngine", "LCMConfig", "SummaryBackend", "CallableBackend"]
@@ -0,0 +1,256 @@
1
+ """Framework adapters for OpenLCM.
2
+
3
+ Two types of adapter live here:
4
+
5
+ 1. **Message converters** — translate message formats between frameworks and
6
+ LCM's internal dict format. Use these when you manage the conversation
7
+ history yourself (raw SDK, custom agent loop, etc.).
8
+
9
+ 2. **Framework lifecycle adapters** — plug LCM into a framework's built-in
10
+ memory / checkpointing / context system. Use these when you want LCM to
11
+ work *transparently* inside an existing framework graph or crew.
12
+
13
+ ──────────────────────────────────────────────────────────────────────────────
14
+ Message converters (``to_lcm`` / ``from_lcm``)
15
+ ──────────────────────────────────────────────────────────────────────────────
16
+
17
+ OpenAI (and every OpenAI-compatible API)::
18
+
19
+ from openlcm.adapters.openai import OpenAIMessages
20
+
21
+ lcm_msgs = OpenAIMessages.to_lcm(openai_messages)
22
+ if engine.should_compress_preflight(lcm_msgs):
23
+ lcm_msgs = await engine.compress(lcm_msgs)
24
+ openai_msgs = OpenAIMessages.from_lcm(lcm_msgs)
25
+
26
+ # Compatible with: OpenAI, Groq, Together, Mistral, Perplexity, Fireworks,
27
+ # Azure OpenAI, Ollama, vLLM, LM Studio, OpenRouter, Anyscale
28
+
29
+ Anthropic::
30
+
31
+ from openlcm.adapters.anthropic import AnthropicMessages
32
+
33
+ lcm_msgs = AnthropicMessages.to_lcm(messages, system=system_prompt)
34
+ system_out, an_msgs = AnthropicMessages.from_lcm(lcm_msgs)
35
+ # Note: Anthropic returns (system_str, messages) because system is a separate param
36
+
37
+ LangChain (any backend)::
38
+
39
+ from openlcm.adapters.langchain import LangChainMessages
40
+
41
+ lcm_msgs = LangChainMessages.to_lcm(lc_messages) # list[BaseMessage]
42
+ lc_msgs = LangChainMessages.from_lcm(lcm_msgs) # list[BaseMessage]
43
+
44
+ # Compatible with: langchain_openai, langchain_anthropic, langchain_google_genai,
45
+ # langchain_cohere, langchain_mistralai, langchain_groq,
46
+ # langchain_ollama, langchain_aws (Bedrock), langchain_together, …
47
+
48
+ LlamaIndex::
49
+
50
+ from openlcm.adapters.llamaindex import LlamaIndexMessages
51
+
52
+ lcm_msgs = LlamaIndexMessages.to_lcm(chat_messages) # list[ChatMessage]
53
+ li_msgs = LlamaIndexMessages.from_lcm(lcm_msgs)
54
+
55
+ # Compatible with: llama_index.llms.openai, llama_index.llms.anthropic,
56
+ # llama_index.llms.gemini, llama_index.llms.ollama, …
57
+
58
+ Haystack v2::
59
+
60
+ from openlcm.adapters.haystack import HaystackMessages
61
+
62
+ lcm_msgs = HaystackMessages.to_lcm(hs_messages) # list[ChatMessage]
63
+ hs_msgs = HaystackMessages.from_lcm(lcm_msgs)
64
+
65
+ # Compatible with: OpenAIChatGenerator, AnthropicChatGenerator,
66
+ # HuggingFaceAPIChatGenerator, AzureOpenAIChatGenerator, …
67
+
68
+ Auto-detect the right converter from your message list::
69
+
70
+ from openlcm.adapters import auto_detect
71
+
72
+ converter = auto_detect(messages)
73
+ lcm_msgs = converter.to_lcm(messages)
74
+
75
+ ──────────────────────────────────────────────────────────────────────────────
76
+ Framework lifecycle adapters
77
+ ──────────────────────────────────────────────────────────────────────────────
78
+
79
+ LangGraph — transparent checkpointing::
80
+
81
+ from openlcm.adapters.langgraph import LCMCheckpointer # pip install openlcm[langgraph]
82
+
83
+ graph = StateGraph(MyState).compile(checkpointer=LCMCheckpointer(llm=my_llm))
84
+
85
+ CrewAI — long-term memory storage::
86
+
87
+ from openlcm.adapters.crewai import LCMStorage # pip install openlcm[crewai]
88
+
89
+ crew = Crew(..., long_term_memory=LongTermMemory(storage=LCMStorage(llm=my_llm)))
90
+
91
+ AutoGen — model context::
92
+
93
+ from openlcm.adapters.autogen import LCMContext # pip install openlcm[autogen]
94
+
95
+ agent = AssistantAgent("bot", model_client=client, model_context=LCMContext(llm=client))
96
+
97
+ Google ADK — session service::
98
+
99
+ from openlcm.adapters.google_adk import LCMSessionService # pip install openlcm[google-adk]
100
+
101
+ runner = Runner(agent=my_agent, session_service=LCMSessionService(llm=model))
102
+ """
103
+
104
+ from __future__ import annotations
105
+
106
+ from typing import Any
107
+
108
+ # Top-level imports so `from openlcm.adapters import X` works directly.
109
+ # Each import is wrapped so the package loads even if a framework isn't installed.
110
+ try:
111
+ from .openai import OpenAIMessages
112
+ except ImportError:
113
+ OpenAIMessages = None # type: ignore[assignment,misc]
114
+
115
+ try:
116
+ from .anthropic import AnthropicMessages
117
+ except ImportError:
118
+ AnthropicMessages = None # type: ignore[assignment,misc]
119
+
120
+ try:
121
+ from .langchain import LangChainMessages
122
+ except ImportError:
123
+ LangChainMessages = None # type: ignore[assignment,misc]
124
+
125
+ try:
126
+ from .llamaindex import LlamaIndexMessages
127
+ except ImportError:
128
+ LlamaIndexMessages = None # type: ignore[assignment,misc]
129
+
130
+ try:
131
+ from .haystack import HaystackMessages
132
+ except ImportError:
133
+ HaystackMessages = None # type: ignore[assignment,misc]
134
+
135
+ try:
136
+ from .gemini import GeminiMessages
137
+ except ImportError:
138
+ GeminiMessages = None # type: ignore[assignment,misc]
139
+
140
+ try:
141
+ from .autogen import AutoGenMessages, LCMContext
142
+ except ImportError:
143
+ AutoGenMessages = None # type: ignore[assignment,misc]
144
+ LCMContext = None # type: ignore[assignment,misc]
145
+
146
+ try:
147
+ from .langgraph import LCMCheckpointer
148
+ except ImportError:
149
+ LCMCheckpointer = None # type: ignore[assignment,misc]
150
+
151
+ try:
152
+ from .crewai import LCMStorage
153
+ except ImportError:
154
+ LCMStorage = None # type: ignore[assignment,misc]
155
+
156
+ try:
157
+ from .google_adk import LCMSessionService, lcm_compress_callback
158
+ except ImportError:
159
+ LCMSessionService = None # type: ignore[assignment,misc]
160
+ lcm_compress_callback = None # type: ignore[assignment,misc]
161
+
162
+
163
+ def auto_detect(messages: list) -> Any:
164
+ """Return the right message converter for the given message list.
165
+
166
+ Inspects the first non-empty element and returns the appropriate converter
167
+ class (with static ``to_lcm`` / ``from_lcm`` methods).
168
+
169
+ Supported detection:
170
+ - LangChain ``BaseMessage`` subclasses → ``LangChainMessages``
171
+ - Anthropic content-block dicts (``type: tool_use``) → ``AnthropicMessages``
172
+ - Haystack ``ChatMessage`` objects → ``HaystackMessages``
173
+ - LlamaIndex ``ChatMessage`` objects → ``LlamaIndexMessages``
174
+ - OpenAI dicts (``tool_calls`` key / plain role+content) → ``OpenAIMessages``
175
+
176
+ Falls back to ``OpenAIMessages`` if the format cannot be determined.
177
+
178
+ Args:
179
+ messages: The conversation list whose format you want to detect.
180
+
181
+ Returns:
182
+ A converter class with static ``to_lcm()`` and ``from_lcm()`` methods.
183
+
184
+ Example::
185
+
186
+ from openlcm.adapters import auto_detect
187
+
188
+ conv = openai_client.chat.completions.create(...).choices[0].message
189
+ converter = auto_detect(my_messages)
190
+ lcm = converter.to_lcm(my_messages)
191
+ """
192
+ if not messages:
193
+ from .openai import OpenAIMessages
194
+ return OpenAIMessages
195
+
196
+ first = next((m for m in messages if m is not None), None)
197
+ if first is None:
198
+ from .openai import OpenAIMessages
199
+ return OpenAIMessages
200
+
201
+ # ── LangChain BaseMessage ─────────────────────────────────────────────────
202
+ try:
203
+ from langchain_core.messages import BaseMessage
204
+ if isinstance(first, BaseMessage):
205
+ from .langchain import LangChainMessages
206
+ return LangChainMessages
207
+ except ImportError:
208
+ pass
209
+
210
+ # ── Haystack ChatMessage ──────────────────────────────────────────────────
211
+ try:
212
+ from haystack.dataclasses import ChatMessage as HaystackCM
213
+ if isinstance(first, HaystackCM):
214
+ from .haystack import HaystackMessages
215
+ return HaystackMessages
216
+ except ImportError:
217
+ pass
218
+
219
+ # ── LlamaIndex ChatMessage ────────────────────────────────────────────────
220
+ try:
221
+ from llama_index.core.llms import ChatMessage as LlamaCM
222
+ if isinstance(first, LlamaCM):
223
+ from .llamaindex import LlamaIndexMessages
224
+ return LlamaIndexMessages
225
+ except ImportError:
226
+ pass
227
+
228
+ # ── Anthropic content-block style ─────────────────────────────────────────
229
+ if isinstance(first, dict):
230
+ content = first.get("content", "")
231
+ if isinstance(content, list) and content:
232
+ block = content[0]
233
+ if isinstance(block, dict) and block.get("type") in ("text", "tool_use", "tool_result", "image"):
234
+ from .anthropic import AnthropicMessages
235
+ return AnthropicMessages
236
+
237
+ # ── Default: OpenAI dict format ───────────────────────────────────────────
238
+ from .openai import OpenAIMessages
239
+ return OpenAIMessages
240
+
241
+
242
+ __all__ = [
243
+ # Auto-detection
244
+ "auto_detect",
245
+ # Message converters
246
+ "OpenAIMessages",
247
+ "AnthropicMessages",
248
+ "LangChainMessages",
249
+ "LlamaIndexMessages",
250
+ "HaystackMessages",
251
+ # Framework lifecycle adapters
252
+ "LCMCheckpointer",
253
+ "LCMStorage",
254
+ "LCMContext",
255
+ "LCMSessionService",
256
+ ]
@@ -0,0 +1,283 @@
1
+ """Anthropic message converter for LCM.
2
+
3
+ Handles Anthropic Claude's message format, including multi-part content blocks,
4
+ tool_use / tool_result pairs, and the separate system parameter.
5
+
6
+ Install: pip install anthropic
7
+
8
+ Quick start::
9
+
10
+ import anthropic
11
+ from openlcm import LCMEngine
12
+ from openlcm.adapters.anthropic import AnthropicMessages
13
+
14
+ client = anthropic.AsyncAnthropic()
15
+ engine = LCMEngine(model="anthropic/claude-haiku-4-5-20251001")
16
+ engine.bind_session("my-session", context_length=200_000)
17
+
18
+ system = "You are a helpful assistant."
19
+ conv = [] # only user / assistant messages (no system in Anthropic conv list)
20
+
21
+ async def chat(user_msg: str) -> str:
22
+ conv.append({"role": "user", "content": user_msg})
23
+
24
+ # Anthropic keeps system separate — AnthropicMessages handles that
25
+ lcm_msgs = AnthropicMessages.to_lcm(conv, system=system)
26
+
27
+ if engine.should_compress_preflight(lcm_msgs):
28
+ lcm_msgs = await engine.compress(lcm_msgs)
29
+ system_out, conv[:] = AnthropicMessages.from_lcm(lcm_msgs)
30
+ # system_out is the same system string passed in (preserved through LCM)
31
+
32
+ resp = await client.messages.create(
33
+ model="claude-haiku-4-5-20251001",
34
+ max_tokens=4096,
35
+ system=system,
36
+ messages=conv,
37
+ tools=MY_TOOLS,
38
+ )
39
+
40
+ # Append assistant message from response
41
+ conv.append({"role": "assistant", "content": resp.content})
42
+ return next((b.text for b in resp.content if hasattr(b, "text")), "")
43
+
44
+ Tool-calling::
45
+
46
+ # After the model returns tool_use blocks, execute them and append results:
47
+ for block in resp.content:
48
+ if block.type == "tool_use":
49
+ result = run_tool(block.name, block.input)
50
+ conv.append({
51
+ "role": "user",
52
+ "content": [{
53
+ "type": "tool_result",
54
+ "tool_use_id": block.id,
55
+ "content": [{"type": "text", "text": json.dumps(result)}],
56
+ }],
57
+ })
58
+ # Then call chat() again.
59
+
60
+ Notes
61
+ -----
62
+ - Anthropic does NOT use a ``system`` role in the messages list. Pass it via
63
+ ``AnthropicMessages.to_lcm(conv, system="...")`` and recover it with the
64
+ second element of ``AnthropicMessages.from_lcm()``'s return value.
65
+ - ``from_lcm()`` returns ``(system_str, messages_list)`` — a 2-tuple — because
66
+ the Anthropic API requires system as a separate top-level parameter.
67
+ - Tool results in Anthropic go back as user-role messages with ``tool_result``
68
+ content blocks. The converter handles this automatically.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import json
74
+ from typing import Any
75
+
76
+
77
+ def _text_from_block(block: Any) -> str:
78
+ """Extract plain text from an Anthropic content block (dict or SDK object)."""
79
+ if isinstance(block, dict):
80
+ return block.get("text", "")
81
+ return getattr(block, "text", "") or ""
82
+
83
+
84
+ def _block_type(block: Any) -> str:
85
+ if isinstance(block, dict):
86
+ return block.get("type", "")
87
+ return getattr(block, "type", "") or ""
88
+
89
+
90
+ class AnthropicMessages:
91
+ """Convert between Anthropic SDK message format and LCM internal format.
92
+
93
+ All methods are static — no instantiation needed::
94
+
95
+ lcm = AnthropicMessages.to_lcm(messages, system="You are...")
96
+ system_str, messages = AnthropicMessages.from_lcm(lcm_messages)
97
+
98
+ LCM internal format
99
+ -------------------
100
+ See ``openlcm.adapters.openai.OpenAIMessages`` for the canonical definition.
101
+ Anthropic-specific mappings:
102
+
103
+ - ``tool_use`` content block → assistant message with JSON ``tool_calls``
104
+ - ``tool_result`` user block → ``{"role":"tool", ...}`` message
105
+ - Multi-part text blocks → joined with newline into a single string
106
+ - System message extracted to the ``system=`` kwarg of ``to_lcm()``
107
+ """
108
+
109
+ @staticmethod
110
+ def to_lcm(messages: list, system: str = "") -> list[dict]:
111
+ """Convert Anthropic messages to LCM internal format.
112
+
113
+ Args:
114
+ messages: The ``messages`` list passed to ``client.messages.create()``.
115
+ Each entry is either a dict or an Anthropic SDK Message object.
116
+ system: The ``system`` parameter from ``client.messages.create()``.
117
+ If provided, it is prepended as a ``{"role":"system"}`` entry.
118
+
119
+ Returns:
120
+ LCM internal message list.
121
+ """
122
+ result: list[dict] = []
123
+
124
+ if system:
125
+ result.append({"role": "system", "content": str(system)})
126
+
127
+ for m in messages:
128
+ role = (m.get("role") if isinstance(m, dict) else getattr(m, "role", "user")) or "user"
129
+ content_raw = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
130
+
131
+ # ── Simple string content ────────────────────────────────────────
132
+ if isinstance(content_raw, str):
133
+ result.append({"role": str(role), "content": content_raw})
134
+ continue
135
+
136
+ # ── SDK Message / ContentBlock objects ───────────────────────────
137
+ if not isinstance(content_raw, list):
138
+ # e.g. Anthropic SDK response object — try to get .content
139
+ content_raw = getattr(content_raw, "content", []) or []
140
+
141
+ # ── List of content blocks ───────────────────────────────────────
142
+ if role == "assistant":
143
+ text_parts: list[str] = []
144
+ tool_calls: list[dict] = []
145
+
146
+ for block in content_raw:
147
+ btype = _block_type(block)
148
+ if btype == "text":
149
+ text_parts.append(_text_from_block(block))
150
+ elif btype == "tool_use":
151
+ bid = (block.get("id") if isinstance(block, dict) else getattr(block, "id", "")) or ""
152
+ bname = (block.get("name") if isinstance(block, dict) else getattr(block, "name", "")) or ""
153
+ binp = (block.get("input") if isinstance(block, dict) else getattr(block, "input", {})) or {}
154
+ tool_calls.append({"id": bid, "name": bname, "args": dict(binp), "type": "function"})
155
+
156
+ if tool_calls:
157
+ content_str = json.dumps(
158
+ {"text": "\n".join(text_parts), "tool_calls": tool_calls},
159
+ ensure_ascii=False,
160
+ )
161
+ else:
162
+ content_str = "\n".join(text_parts)
163
+ result.append({"role": "assistant", "content": content_str})
164
+
165
+ elif role == "user":
166
+ text_parts = []
167
+ tool_results: list[dict] = []
168
+
169
+ for block in content_raw:
170
+ btype = _block_type(block)
171
+ if btype == "text":
172
+ text_parts.append(_text_from_block(block))
173
+ elif btype == "tool_result":
174
+ tr_id = (block.get("tool_use_id") if isinstance(block, dict) else getattr(block, "tool_use_id", "")) or ""
175
+ tr_cont = (block.get("content") if isinstance(block, dict) else getattr(block, "content", "")) or ""
176
+ # content may itself be a list of text blocks
177
+ if isinstance(tr_cont, list):
178
+ tr_text = "\n".join(
179
+ _text_from_block(b) for b in tr_cont if _block_type(b) == "text"
180
+ )
181
+ elif isinstance(tr_cont, str):
182
+ tr_text = tr_cont
183
+ else:
184
+ tr_text = str(tr_cont)
185
+ tool_results.append({
186
+ "role": "tool",
187
+ "content": tr_text,
188
+ "tool_call_id": tr_id,
189
+ "name": "",
190
+ })
191
+
192
+ # Tool results come first; any remaining text becomes a user message
193
+ result.extend(tool_results)
194
+ if text_parts:
195
+ result.append({"role": "user", "content": "\n".join(text_parts)})
196
+
197
+ else:
198
+ # system role inside messages list (uncommon but handle it)
199
+ text = "\n".join(
200
+ _text_from_block(b) for b in content_raw if _block_type(b) == "text"
201
+ ) or str(content_raw)
202
+ result.append({"role": str(role), "content": text})
203
+
204
+ return result
205
+
206
+ @staticmethod
207
+ def from_lcm(messages: list[dict]) -> tuple[str, list[dict]]:
208
+ """Convert LCM internal format to Anthropic-ready messages.
209
+
210
+ Args:
211
+ messages: LCM internal message list (from ``engine.compress()``).
212
+
213
+ Returns:
214
+ ``(system_str, anthropic_messages)`` where:
215
+ - ``system_str`` is the value for ``client.messages.create(system=...)``.
216
+ - ``anthropic_messages`` is the value for ``client.messages.create(messages=...)``.
217
+
218
+ Usage::
219
+
220
+ system, msgs = AnthropicMessages.from_lcm(lcm_messages)
221
+ resp = await client.messages.create(system=system, messages=msgs, ...)
222
+ """
223
+ system = ""
224
+ result: list[dict] = []
225
+ pending_tool_results: list[dict] = []
226
+
227
+ for m in messages:
228
+ role = (m.get("role") or "user").lower()
229
+ content = m.get("content", "")
230
+
231
+ # ── system ───────────────────────────────────────────────────────
232
+ if role == "system":
233
+ system = content
234
+ continue
235
+
236
+ # Flush accumulated tool_result blocks when a non-tool message arrives
237
+ if pending_tool_results and role != "tool":
238
+ result.append({"role": "user", "content": pending_tool_results})
239
+ pending_tool_results = []
240
+
241
+ # ── assistant ────────────────────────────────────────────────────
242
+ if role == "assistant":
243
+ try:
244
+ parsed = json.loads(content)
245
+ if isinstance(parsed, dict) and "tool_calls" in parsed:
246
+ blocks: list[dict] = []
247
+ text = parsed.get("text", "")
248
+ if text:
249
+ blocks.append({"type": "text", "text": text})
250
+ for tc in parsed["tool_calls"]:
251
+ blocks.append({
252
+ "type": "tool_use",
253
+ "id": tc.get("id", ""),
254
+ "name": tc.get("name", ""),
255
+ "input": tc.get("args", {}),
256
+ })
257
+ result.append({"role": "assistant", "content": blocks})
258
+ continue
259
+ except (ValueError, TypeError):
260
+ pass
261
+ result.append({"role": "assistant", "content": content})
262
+
263
+ # ── tool result ──────────────────────────────────────────────────
264
+ elif role == "tool":
265
+ # Anthropic wraps tool_result blocks inside a user-role message
266
+ pending_tool_results.append({
267
+ "type": "tool_result",
268
+ "tool_use_id": m.get("tool_call_id", ""),
269
+ "content": [{"type": "text", "text": content}],
270
+ })
271
+
272
+ # ── user ─────────────────────────────────────────────────────────
273
+ elif role == "user":
274
+ result.append({"role": "user", "content": content})
275
+
276
+ else:
277
+ result.append({"role": role, "content": content})
278
+
279
+ # Flush any remaining tool_results
280
+ if pending_tool_results:
281
+ result.append({"role": "user", "content": pending_tool_results})
282
+
283
+ return system, result