agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,907 @@
1
+ """Claude Code storage provider - reads/writes to ~/.claude format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from decimal import Decimal
7
+ import json
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
12
+ from pydantic.alias_generators import to_camel
13
+ from pydantic_ai import RunUsage
14
+ from pydantic_ai.messages import (
15
+ ModelRequest,
16
+ ModelResponse,
17
+ TextPart,
18
+ ThinkingPart,
19
+ ToolCallPart,
20
+ ToolReturnPart,
21
+ UserPromptPart,
22
+ )
23
+ from pydantic_ai.usage import RequestUsage
24
+
25
+ from agentpool.common_types import MessageRole
26
+ from agentpool.log import get_logger
27
+ from agentpool.messaging import ChatMessage, TokenCost
28
+ from agentpool.utils.now import get_now
29
+ from agentpool_storage.base import StorageProvider
30
+ from agentpool_storage.models import TokenUsage
31
+
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Sequence
35
+
36
+ from pydantic_ai import FinishReason
37
+
38
+ from agentpool_config.session import SessionQuery
39
+ from agentpool_config.storage import ClaudeStorageConfig
40
+ from agentpool_storage.models import ConversationData, MessageData, QueryFilters, StatsFilters
41
+
42
+ logger = get_logger(__name__)
43
+
44
+
45
+ # Claude JSONL message types
46
+
47
+ StopReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None
48
+ ContentType = Literal["text", "tool_use", "tool_result", "thinking"]
49
+ MessageType = Literal[
50
+ "user", "assistant", "queue-operation", "system", "summary", "file-history-snapshot"
51
+ ]
52
+ UserType = Literal["external", "internal"]
53
+
54
+
55
+ class ClaudeBaseModel(BaseModel):
56
+ """Base class for Claude history models."""
57
+
58
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
59
+
60
+
61
+ class ClaudeUsage(BaseModel):
62
+ """Token usage from Claude API response."""
63
+
64
+ input_tokens: int = 0
65
+ output_tokens: int = 0
66
+ cache_creation_input_tokens: int = 0
67
+ cache_read_input_tokens: int = 0
68
+
69
+
70
+ class ClaudeMessageContent(BaseModel):
71
+ """Content block in Claude message.
72
+
73
+ Supports: text, tool_use, tool_result, thinking blocks.
74
+ """
75
+
76
+ type: ContentType
77
+ # For text blocks
78
+ text: str | None = None
79
+ # For tool_use blocks
80
+ id: str | None = None
81
+ name: str | None = None
82
+ input: dict[str, Any] | None = None
83
+ # For tool_result blocks
84
+ tool_use_id: str | None = None
85
+ content: list[dict[str, Any]] | str | None = None # Can be array or string
86
+ is_error: bool | None = None
87
+ # For thinking blocks
88
+ thinking: str | None = None
89
+ signature: str | None = None
90
+
91
+
92
+ class ClaudeApiMessage(BaseModel):
93
+ """Claude API message structure."""
94
+
95
+ model: str
96
+ id: str
97
+ type: Literal["message"] = "message"
98
+ role: Literal["assistant"]
99
+ content: str | list[ClaudeMessageContent]
100
+ stop_reason: StopReason = None
101
+ usage: ClaudeUsage = Field(default_factory=ClaudeUsage)
102
+
103
+
104
+ class ClaudeUserMessage(BaseModel):
105
+ """User message content."""
106
+
107
+ role: Literal["user"]
108
+ content: str | list[ClaudeMessageContent]
109
+
110
+
111
+ class ClaudeMessageEntryBase(ClaudeBaseModel):
112
+ """Base for user/assistant message entries."""
113
+
114
+ uuid: str
115
+ parent_uuid: str | None = None
116
+ session_id: str = Field(alias="sessionId")
117
+ timestamp: str
118
+ message: ClaudeApiMessage | ClaudeUserMessage
119
+
120
+ # Context (NOT USED directly)
121
+ cwd: str = ""
122
+ git_branch: str = ""
123
+ version: str = ""
124
+
125
+ # Metadata (NOT USED)
126
+ user_type: UserType = "external"
127
+ is_sidechain: bool = False
128
+ request_id: str | None = None
129
+ agent_id: str | None = None
130
+ # toolUseResult can be list, dict, or string (error message)
131
+ tool_use_result: list[dict[str, Any]] | dict[str, Any] | str | None = None
132
+
133
+ model_config = ConfigDict(populate_by_name=True)
134
+
135
+
136
+ class ClaudeUserEntry(ClaudeMessageEntryBase):
137
+ """User message entry."""
138
+
139
+ type: Literal["user"]
140
+
141
+
142
+ class ClaudeAssistantEntry(ClaudeMessageEntryBase):
143
+ """Assistant message entry."""
144
+
145
+ type: Literal["assistant"]
146
+
147
+
148
+ class ClaudeQueueOperationEntry(ClaudeBaseModel):
149
+ """Queue operation entry (not a message)."""
150
+
151
+ type: Literal["queue-operation"]
152
+ session_id: str
153
+ timestamp: str
154
+ operation: str
155
+
156
+ model_config = ConfigDict(populate_by_name=True)
157
+
158
+
159
+ class ClaudeSystemEntry(ClaudeBaseModel):
160
+ """System message entry (context, prompts, etc.)."""
161
+
162
+ type: Literal["system"]
163
+ uuid: str
164
+ parent_uuid: str | None = None
165
+ session_id: str
166
+ timestamp: str
167
+ content: str
168
+ subtype: str | None = None
169
+ slug: str | None = None
170
+ level: int | str | None = None
171
+ is_meta: bool = False
172
+ logical_parent_uuid: str | None = None
173
+ compact_metadata: dict[str, Any] | None = None
174
+ # Common fields
175
+ cwd: str = ""
176
+ git_branch: str = ""
177
+ version: str = ""
178
+ user_type: UserType = "external"
179
+ is_sidechain: bool = False
180
+
181
+ model_config = ConfigDict(populate_by_name=True)
182
+
183
+
184
+ class ClaudeSummaryEntry(ClaudeBaseModel):
185
+ """Summary entry (conversation summary)."""
186
+
187
+ type: Literal["summary"]
188
+ leaf_uuid: str
189
+ summary: str
190
+
191
+ model_config = ConfigDict(populate_by_name=True)
192
+
193
+
194
+ class ClaudeFileHistoryEntry(ClaudeBaseModel):
195
+ """File history snapshot entry."""
196
+
197
+ type: Literal["file-history-snapshot"]
198
+ message_id: str
199
+ snapshot: dict[str, Any]
200
+ is_snapshot_update: bool = False
201
+
202
+ model_config = ConfigDict(populate_by_name=True)
203
+
204
+
205
+ # Discriminated union for all entry types
206
+ ClaudeJSONLEntry = Annotated[
207
+ ClaudeUserEntry
208
+ | ClaudeAssistantEntry
209
+ | ClaudeQueueOperationEntry
210
+ | ClaudeSystemEntry
211
+ | ClaudeSummaryEntry
212
+ | ClaudeFileHistoryEntry,
213
+ Field(discriminator="type"),
214
+ ]
215
+
216
+
217
+ class ClaudeStorageProvider(StorageProvider):
218
+ """Storage provider that reads/writes Claude Code's native format.
219
+
220
+ Claude stores conversations as JSONL files in:
221
+ - ~/.claude/projects/{path-encoded-project-name}/{session-id}.jsonl
222
+
223
+ Each line is a JSON object representing a message in the conversation.
224
+
225
+ ## Fields NOT currently used from Claude format:
226
+ - `isSidechain`: Whether message is on a side branch
227
+ - `userType`: Type of user ("external", etc.)
228
+ - `cwd`: Working directory at time of message
229
+ - `gitBranch`: Git branch at time of message
230
+ - `version`: Claude CLI version
231
+ - `requestId`: API request ID
232
+ - `agentId`: Agent identifier for subagents
233
+ - `toolUseResult`: Detailed tool result content (we extract text only)
234
+ - `parentUuid`: Parent message for threading (we use flat history)
235
+
236
+ ## Additional Claude data not handled:
237
+ - `~/.claude/todos/`: Todo lists per session
238
+ - `~/.claude/plans/`: Markdown plan files
239
+ - `~/.claude/skills/`: Custom skills
240
+ - `~/.claude/history.jsonl`: Command/prompt history
241
+ """
242
+
243
+ can_load_history = True
244
+
245
+ def __init__(self, config: ClaudeStorageConfig) -> None:
246
+ """Initialize Claude storage provider.
247
+
248
+ Args:
249
+ config: Configuration for the provider
250
+ """
251
+ super().__init__(config)
252
+ self.base_path = Path(config.path).expanduser()
253
+ self.projects_path = self.base_path / "projects"
254
+ self._ensure_dirs()
255
+
256
+ def _ensure_dirs(self) -> None:
257
+ """Ensure required directories exist."""
258
+ self.projects_path.mkdir(parents=True, exist_ok=True)
259
+
260
+ def _encode_project_path(self, path: str) -> str:
261
+ """Encode a project path to Claude's format.
262
+
263
+ Claude encodes paths by replacing / with - and prepending -.
264
+ Example: /home/user/project -> -home-user-project
265
+ """
266
+ return path.replace("/", "-")
267
+
268
+ def _decode_project_path(self, encoded: str) -> str:
269
+ """Decode a Claude project path back to filesystem path.
270
+
271
+ Example: -home-user-project -> /home/user/project
272
+ """
273
+ if encoded.startswith("-"):
274
+ encoded = encoded[1:]
275
+ return "/" + encoded.replace("-", "/")
276
+
277
+ def _get_project_dir(self, project_path: str) -> Path:
278
+ """Get the directory for a project's conversations."""
279
+ encoded = self._encode_project_path(project_path)
280
+ return self.projects_path / encoded
281
+
282
+ def _list_sessions(self, project_path: str | None = None) -> list[tuple[str, Path]]:
283
+ """List all sessions, optionally filtered by project.
284
+
285
+ Returns:
286
+ List of (session_id, file_path) tuples
287
+ """
288
+ sessions = []
289
+ if project_path:
290
+ project_dir = self._get_project_dir(project_path)
291
+ if project_dir.exists():
292
+ for f in project_dir.glob("*.jsonl"):
293
+ session_id = f.stem
294
+ sessions.append((session_id, f))
295
+ else:
296
+ for project_dir in self.projects_path.iterdir():
297
+ if project_dir.is_dir():
298
+ for f in project_dir.glob("*.jsonl"):
299
+ session_id = f.stem
300
+ sessions.append((session_id, f))
301
+ return sessions
302
+
303
+ def _read_session(self, session_path: Path) -> list[ClaudeJSONLEntry]:
304
+ """Read all entries from a session file."""
305
+ entries: list[ClaudeJSONLEntry] = []
306
+ if not session_path.exists():
307
+ return entries
308
+
309
+ adapter = TypeAdapter[Any](ClaudeJSONLEntry)
310
+ with session_path.open("r", encoding="utf-8") as f:
311
+ for raw_line in f:
312
+ stripped = raw_line.strip()
313
+ if not stripped:
314
+ continue
315
+ try:
316
+ data = json.loads(stripped)
317
+ entry = adapter.validate_python(data)
318
+ entries.append(entry)
319
+ except (json.JSONDecodeError, ValueError) as e:
320
+ logger.warning(
321
+ "Failed to parse JSONL line", path=str(session_path), error=str(e)
322
+ )
323
+ return entries
324
+
325
+ def _write_entry(self, session_path: Path, entry: ClaudeJSONLEntry) -> None:
326
+ """Append an entry to a session file."""
327
+ session_path.parent.mkdir(parents=True, exist_ok=True)
328
+ with session_path.open("a", encoding="utf-8") as f:
329
+ f.write(entry.model_dump_json(by_alias=True) + "\n")
330
+
331
+ def _build_tool_id_mapping(self, entries: list[ClaudeJSONLEntry]) -> dict[str, str]:
332
+ """Build a mapping from tool_call_id to tool_name from assistant entries."""
333
+ mapping: dict[str, str] = {}
334
+ for entry in entries:
335
+ if not isinstance(entry, ClaudeAssistantEntry):
336
+ continue
337
+ msg = entry.message
338
+ if not isinstance(msg.content, list):
339
+ continue
340
+ for block in msg.content:
341
+ if block.type == "tool_use" and block.id and block.name:
342
+ mapping[block.id] = block.name
343
+ return mapping
344
+
345
+ def _entry_to_chat_message(
346
+ self,
347
+ entry: ClaudeJSONLEntry,
348
+ conversation_id: str,
349
+ tool_id_mapping: dict[str, str] | None = None,
350
+ ) -> ChatMessage[str] | None:
351
+ """Convert a Claude JSONL entry to a ChatMessage.
352
+
353
+ Reconstructs pydantic-ai ModelRequest/ModelResponse objects and stores
354
+ them in the messages field for full fidelity.
355
+
356
+ Args:
357
+ entry: The JSONL entry to convert
358
+ conversation_id: ID for the conversation
359
+ tool_id_mapping: Optional mapping from tool_call_id to tool_name
360
+ for resolving tool names in ToolReturnPart
361
+
362
+ Returns None for non-message entries (queue-operation, summary, etc.).
363
+ """
364
+ # Only handle user/assistant entries with messages
365
+ if not isinstance(entry, (ClaudeUserEntry, ClaudeAssistantEntry)):
366
+ return None
367
+
368
+ message = entry.message
369
+
370
+ # Parse timestamp
371
+ try:
372
+ timestamp = datetime.fromisoformat(entry.timestamp.replace("Z", "+00:00"))
373
+ except (ValueError, AttributeError):
374
+ timestamp = get_now()
375
+
376
+ # Extract display content (text only for UI)
377
+ content = self._extract_text_content(message)
378
+
379
+ # Build pydantic-ai message
380
+ pydantic_message = self._build_pydantic_message(
381
+ entry, message, timestamp, tool_id_mapping or {}
382
+ )
383
+
384
+ # Extract token usage and cost
385
+ cost_info = None
386
+ model = None
387
+ finish_reason = None
388
+ if isinstance(entry, ClaudeAssistantEntry) and isinstance(message, ClaudeApiMessage):
389
+ usage = message.usage
390
+ input_tokens = (
391
+ usage.input_tokens
392
+ + usage.cache_read_input_tokens
393
+ + usage.cache_creation_input_tokens
394
+ )
395
+ output_tokens = usage.output_tokens
396
+
397
+ if input_tokens or output_tokens:
398
+ cost_info = TokenCost(
399
+ token_usage=RunUsage(
400
+ input_tokens=input_tokens,
401
+ output_tokens=output_tokens,
402
+ ),
403
+ total_cost=Decimal(0), # Claude doesn't store cost directly
404
+ )
405
+ model = message.model
406
+ finish_reason = message.stop_reason
407
+
408
+ return ChatMessage[str](
409
+ content=content,
410
+ conversation_id=conversation_id,
411
+ role=entry.type,
412
+ message_id=entry.uuid,
413
+ name="claude" if isinstance(entry, ClaudeAssistantEntry) else None,
414
+ model_name=model,
415
+ cost_info=cost_info,
416
+ timestamp=timestamp,
417
+ parent_id=entry.parent_uuid,
418
+ messages=[pydantic_message] if pydantic_message else [],
419
+ provider_details={"finish_reason": finish_reason} if finish_reason else {},
420
+ )
421
+
422
+ def _extract_text_content(self, message: ClaudeApiMessage | ClaudeUserMessage) -> str:
423
+ """Extract text content from a Claude message for display.
424
+
425
+ Only extracts text and thinking blocks, not tool calls/results.
426
+ """
427
+ msg_content = message.content
428
+ if isinstance(msg_content, str):
429
+ return msg_content
430
+
431
+ text_parts: list[str] = []
432
+ for part in msg_content:
433
+ if part.type == "text" and part.text:
434
+ text_parts.append(part.text)
435
+ elif part.type == "thinking" and part.thinking:
436
+ # Include thinking in display content
437
+ text_parts.append(f"<thinking>\n{part.thinking}\n</thinking>")
438
+ return "\n".join(text_parts)
439
+
440
+ def _build_pydantic_message(
441
+ self,
442
+ entry: ClaudeUserEntry | ClaudeAssistantEntry,
443
+ message: ClaudeApiMessage | ClaudeUserMessage,
444
+ timestamp: datetime,
445
+ tool_id_mapping: dict[str, str],
446
+ ) -> ModelRequest | ModelResponse | None:
447
+ """Build a pydantic-ai ModelRequest or ModelResponse from Claude data.
448
+
449
+ Args:
450
+ entry: The entry being converted
451
+ message: The message content
452
+ timestamp: Parsed timestamp
453
+ tool_id_mapping: Mapping from tool_call_id to tool_name
454
+ """
455
+ msg_content = message.content
456
+
457
+ if isinstance(entry, ClaudeUserEntry):
458
+ # Build ModelRequest with user prompt parts
459
+ parts: list[UserPromptPart | ToolReturnPart] = []
460
+
461
+ if isinstance(msg_content, str):
462
+ parts.append(UserPromptPart(content=msg_content, timestamp=timestamp))
463
+ else:
464
+ for block in msg_content:
465
+ if block.type == "text" and block.text:
466
+ parts.append(UserPromptPart(content=block.text, timestamp=timestamp))
467
+ elif block.type == "tool_result" and block.tool_use_id:
468
+ # Reconstruct tool return - look up tool name from mapping
469
+ tool_content = self._extract_tool_result_content(block)
470
+ tool_name = tool_id_mapping.get(block.tool_use_id, "")
471
+ parts.append(
472
+ ToolReturnPart(
473
+ tool_name=tool_name,
474
+ content=tool_content,
475
+ tool_call_id=block.tool_use_id,
476
+ timestamp=timestamp,
477
+ )
478
+ )
479
+
480
+ return ModelRequest(parts=parts, timestamp=timestamp) if parts else None
481
+
482
+ # Build ModelResponse for assistant
483
+ if not isinstance(message, ClaudeApiMessage):
484
+ return None
485
+
486
+ response_parts: list[TextPart | ToolCallPart | ThinkingPart] = []
487
+ usage = RequestUsage(
488
+ input_tokens=message.usage.input_tokens,
489
+ output_tokens=message.usage.output_tokens,
490
+ cache_read_tokens=message.usage.cache_read_input_tokens,
491
+ cache_write_tokens=message.usage.cache_creation_input_tokens,
492
+ )
493
+
494
+ if isinstance(msg_content, str):
495
+ response_parts.append(TextPart(content=msg_content))
496
+ else:
497
+ for block in msg_content:
498
+ if block.type == "text" and block.text:
499
+ response_parts.append(TextPart(content=block.text))
500
+ elif block.type == "thinking" and block.thinking:
501
+ response_parts.append(
502
+ ThinkingPart(
503
+ content=block.thinking,
504
+ signature=block.signature,
505
+ )
506
+ )
507
+ elif block.type == "tool_use" and block.id and block.name:
508
+ response_parts.append(
509
+ ToolCallPart(
510
+ tool_name=block.name,
511
+ args=block.input or {},
512
+ tool_call_id=block.id,
513
+ )
514
+ )
515
+
516
+ if not response_parts:
517
+ return None
518
+
519
+ return ModelResponse(
520
+ parts=response_parts,
521
+ usage=usage,
522
+ model_name=message.model,
523
+ timestamp=timestamp,
524
+ )
525
+
526
+ def _extract_tool_result_content(self, block: ClaudeMessageContent) -> str:
527
+ """Extract content from a tool_result block."""
528
+ if block.content is None:
529
+ return ""
530
+ if isinstance(block.content, str):
531
+ return block.content
532
+ # List of content dicts
533
+ text_parts = [
534
+ tc.get("text", "")
535
+ for tc in block.content
536
+ if isinstance(tc, dict) and tc.get("type") == "text"
537
+ ]
538
+ return "\n".join(text_parts)
539
+
540
+ def _chat_message_to_entry(
541
+ self,
542
+ message: ChatMessage[str],
543
+ session_id: str,
544
+ parent_uuid: str | None = None,
545
+ cwd: str | None = None,
546
+ ) -> ClaudeUserEntry | ClaudeAssistantEntry:
547
+ """Convert a ChatMessage to a Claude JSONL entry."""
548
+ import uuid
549
+
550
+ msg_uuid = message.message_id or str(uuid.uuid4())
551
+ timestamp = (message.timestamp or get_now()).isoformat().replace("+00:00", "Z")
552
+
553
+ # Build entry based on role
554
+ if message.role == "user":
555
+ user_msg = ClaudeUserMessage(role="user", content=message.content)
556
+ return ClaudeUserEntry(
557
+ type="user",
558
+ uuid=msg_uuid,
559
+ parentUuid=parent_uuid,
560
+ sessionId=session_id,
561
+ timestamp=timestamp,
562
+ message=user_msg,
563
+ cwd=cwd or "",
564
+ version="agentpool",
565
+ userType="external",
566
+ isSidechain=False,
567
+ )
568
+
569
+ # Assistant message
570
+ content_blocks = [ClaudeMessageContent(type="text", text=message.content)]
571
+ usage = ClaudeUsage()
572
+ if message.cost_info:
573
+ usage = ClaudeUsage(
574
+ input_tokens=message.cost_info.token_usage.input_tokens,
575
+ output_tokens=message.cost_info.token_usage.output_tokens,
576
+ )
577
+ assistant_msg = ClaudeApiMessage(
578
+ model=message.model_name or "unknown",
579
+ id=f"msg_{msg_uuid[:20]}",
580
+ role="assistant",
581
+ content=content_blocks,
582
+ usage=usage,
583
+ )
584
+ return ClaudeAssistantEntry(
585
+ type="assistant",
586
+ uuid=msg_uuid,
587
+ parentUuid=parent_uuid,
588
+ sessionId=session_id,
589
+ timestamp=timestamp,
590
+ message=assistant_msg,
591
+ cwd=cwd or "",
592
+ version="agentpool",
593
+ userType="external",
594
+ isSidechain=False,
595
+ )
596
+
597
+ async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]:
598
+ """Filter messages based on query."""
599
+ messages: list[ChatMessage[str]] = []
600
+
601
+ # Determine which sessions to search
602
+ sessions = self._list_sessions()
603
+
604
+ for session_id, session_path in sessions:
605
+ # Filter by conversation/session name if specified
606
+ if query.name and session_id != query.name:
607
+ continue
608
+
609
+ entries = self._read_session(session_path)
610
+ tool_mapping = self._build_tool_id_mapping(entries)
611
+
612
+ for entry in entries:
613
+ msg = self._entry_to_chat_message(entry, session_id, tool_mapping)
614
+ if msg is None:
615
+ continue
616
+
617
+ # Apply filters
618
+ if query.agents and msg.name not in query.agents:
619
+ continue
620
+
621
+ cutoff = query.get_time_cutoff()
622
+ if query.since and cutoff and msg.timestamp and msg.timestamp < cutoff:
623
+ continue
624
+
625
+ if query.until and msg.timestamp:
626
+ until_dt = datetime.fromisoformat(query.until)
627
+ if msg.timestamp > until_dt:
628
+ continue
629
+
630
+ if query.contains and query.contains not in msg.content:
631
+ continue
632
+
633
+ if query.roles and msg.role not in query.roles:
634
+ continue
635
+
636
+ messages.append(msg)
637
+
638
+ if query.limit and len(messages) >= query.limit:
639
+ return messages
640
+
641
+ return messages
642
+
643
+ async def log_message(
644
+ self,
645
+ *,
646
+ message_id: str,
647
+ conversation_id: str,
648
+ content: str,
649
+ role: str,
650
+ name: str | None = None,
651
+ parent_id: str | None = None,
652
+ cost_info: TokenCost | None = None,
653
+ model: str | None = None,
654
+ response_time: float | None = None,
655
+ forwarded_from: list[str] | None = None,
656
+ provider_name: str | None = None,
657
+ provider_response_id: str | None = None,
658
+ messages: str | None = None,
659
+ finish_reason: FinishReason | None = None,
660
+ ) -> None:
661
+ """Log a message to Claude format.
662
+
663
+ Note: conversation_id should be in format "project_path:session_id"
664
+ or just "session_id" (will use default project).
665
+ """
666
+ # Parse conversation_id
667
+ if ":" in conversation_id:
668
+ project_path, session_id = conversation_id.split(":", 1)
669
+ else:
670
+ project_path = "/tmp"
671
+ session_id = conversation_id
672
+
673
+ # Build ChatMessage for conversion
674
+ chat_message = ChatMessage[str](
675
+ content=content,
676
+ conversation_id=conversation_id,
677
+ role=cast(MessageRole, role),
678
+ message_id=message_id,
679
+ name=name,
680
+ model_name=model,
681
+ cost_info=cost_info,
682
+ response_time=response_time,
683
+ parent_id=parent_id,
684
+ )
685
+
686
+ # Convert to entry and write
687
+ entry = self._chat_message_to_entry(
688
+ chat_message,
689
+ session_id=session_id,
690
+ parent_uuid=parent_id,
691
+ cwd=project_path,
692
+ )
693
+
694
+ session_path = self._get_project_dir(project_path) / f"{session_id}.jsonl"
695
+ self._write_entry(session_path, entry)
696
+
697
+ async def log_conversation(
698
+ self,
699
+ *,
700
+ conversation_id: str,
701
+ node_name: str,
702
+ start_time: datetime | None = None,
703
+ ) -> None:
704
+ """Log a conversation start.
705
+
706
+ In Claude format, conversations are implicit (created when first message is written).
707
+ This is a no-op but could be extended to create an initial entry.
708
+ """
709
+
710
+ async def get_conversations(
711
+ self,
712
+ filters: QueryFilters,
713
+ ) -> list[tuple[ConversationData, Sequence[ChatMessage[str]]]]:
714
+ """Get filtered conversations with their messages."""
715
+ from agentpool_storage.models import ConversationData as ConvData
716
+
717
+ result: list[tuple[ConvData, Sequence[ChatMessage[str]]]] = []
718
+ sessions = self._list_sessions()
719
+
720
+ for session_id, session_path in sessions:
721
+ entries = self._read_session(session_path)
722
+ if not entries:
723
+ continue
724
+
725
+ tool_mapping = self._build_tool_id_mapping(entries)
726
+
727
+ # Build messages
728
+ messages: list[ChatMessage[str]] = []
729
+ first_timestamp: datetime | None = None
730
+ total_tokens = 0
731
+
732
+ for entry in entries:
733
+ msg = self._entry_to_chat_message(entry, session_id, tool_mapping)
734
+ if msg is None:
735
+ continue
736
+
737
+ messages.append(msg)
738
+
739
+ if first_timestamp is None and msg.timestamp:
740
+ first_timestamp = msg.timestamp
741
+
742
+ if msg.cost_info:
743
+ total_tokens += msg.cost_info.token_usage.total_tokens
744
+
745
+ if not messages:
746
+ continue
747
+
748
+ # Apply filters
749
+ if filters.agent_name and not any(m.name == filters.agent_name for m in messages):
750
+ continue
751
+
752
+ if filters.since and first_timestamp and first_timestamp < filters.since:
753
+ continue
754
+
755
+ if filters.query and not any(filters.query in m.content for m in messages):
756
+ continue
757
+
758
+ # Build MessageData list
759
+ msg_data_list: list[MessageData] = []
760
+ for msg in messages:
761
+ msg_data: MessageData = {
762
+ "role": msg.role,
763
+ "content": msg.content,
764
+ "timestamp": (msg.timestamp or get_now()).isoformat(),
765
+ "parent_id": msg.parent_id,
766
+ "model": msg.model_name,
767
+ "name": msg.name,
768
+ "token_usage": TokenUsage(
769
+ total=msg.cost_info.token_usage.total_tokens if msg.cost_info else 0,
770
+ prompt=msg.cost_info.token_usage.input_tokens if msg.cost_info else 0,
771
+ completion=msg.cost_info.token_usage.output_tokens if msg.cost_info else 0,
772
+ )
773
+ if msg.cost_info
774
+ else None,
775
+ "cost": float(msg.cost_info.total_cost) if msg.cost_info else None,
776
+ "response_time": msg.response_time,
777
+ }
778
+ msg_data_list.append(msg_data)
779
+
780
+ token_usage_data: TokenUsage | None = (
781
+ {"total": total_tokens, "prompt": 0, "completion": 0} if total_tokens else None
782
+ )
783
+ conv_data = ConvData(
784
+ id=session_id,
785
+ agent=messages[0].name or "claude",
786
+ title=None,
787
+ start_time=(first_timestamp or get_now()).isoformat(),
788
+ messages=msg_data_list,
789
+ token_usage=token_usage_data,
790
+ )
791
+
792
+ result.append((conv_data, messages))
793
+
794
+ if filters.limit and len(result) >= filters.limit:
795
+ break
796
+
797
+ return result
798
+
799
+ async def get_conversation_stats(
800
+ self,
801
+ filters: StatsFilters,
802
+ ) -> dict[str, dict[str, Any]]:
803
+ """Get conversation statistics."""
804
+ from collections import defaultdict
805
+
806
+ stats: dict[str, dict[str, Any]] = defaultdict(
807
+ lambda: {"total_tokens": 0, "messages": 0, "models": set()}
808
+ )
809
+
810
+ sessions = self._list_sessions()
811
+
812
+ for _session_id, session_path in sessions:
813
+ entries = self._read_session(session_path)
814
+
815
+ for entry in entries:
816
+ if not isinstance(entry, ClaudeAssistantEntry):
817
+ continue
818
+
819
+ if not isinstance(entry.message, ClaudeApiMessage):
820
+ continue
821
+
822
+ api_msg = entry.message
823
+ model = api_msg.model
824
+ usage = api_msg.usage
825
+ total_tokens = (
826
+ usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens
827
+ )
828
+
829
+ try:
830
+ timestamp = datetime.fromisoformat(entry.timestamp.replace("Z", "+00:00"))
831
+ except (ValueError, AttributeError):
832
+ timestamp = get_now()
833
+
834
+ # Apply time filter
835
+ if timestamp < filters.cutoff:
836
+ continue
837
+
838
+ # Group by specified criterion
839
+ match filters.group_by:
840
+ case "model":
841
+ key = model
842
+ case "hour":
843
+ key = timestamp.strftime("%Y-%m-%d %H:00")
844
+ case "day":
845
+ key = timestamp.strftime("%Y-%m-%d")
846
+ case _:
847
+ key = "claude" # Default agent grouping
848
+
849
+ stats[key]["messages"] += 1
850
+ stats[key]["total_tokens"] += total_tokens
851
+ stats[key]["models"].add(model)
852
+
853
+ # Convert sets to lists for JSON serialization
854
+ for value in stats.values():
855
+ value["models"] = list(value["models"])
856
+
857
+ return dict(stats)
858
+
859
+ async def reset(
860
+ self,
861
+ *,
862
+ agent_name: str | None = None,
863
+ hard: bool = False,
864
+ ) -> tuple[int, int]:
865
+ """Reset storage.
866
+
867
+ Warning: This will delete Claude conversation files!
868
+ """
869
+ conv_count = 0
870
+ msg_count = 0
871
+
872
+ sessions = self._list_sessions()
873
+
874
+ for _session_id, session_path in sessions:
875
+ entries = self._read_session(session_path)
876
+ msg_count += len([
877
+ e for e in entries if isinstance(e, (ClaudeUserEntry, ClaudeAssistantEntry))
878
+ ])
879
+ conv_count += 1
880
+
881
+ if hard or not agent_name:
882
+ session_path.unlink(missing_ok=True)
883
+
884
+ return conv_count, msg_count
885
+
886
+ async def get_conversation_counts(
887
+ self,
888
+ *,
889
+ agent_name: str | None = None,
890
+ ) -> tuple[int, int]:
891
+ """Get counts of conversations and messages."""
892
+ conv_count = 0
893
+ msg_count = 0
894
+
895
+ sessions = self._list_sessions()
896
+
897
+ for _session_id, session_path in sessions:
898
+ entries = self._read_session(session_path)
899
+ message_entries = [
900
+ e for e in entries if isinstance(e, (ClaudeUserEntry, ClaudeAssistantEntry))
901
+ ]
902
+
903
+ if message_entries:
904
+ conv_count += 1
905
+ msg_count += len(message_entries)
906
+
907
+ return conv_count, msg_count