diff --git a/.zed/settings.json b/.zed/settings.json index f140ebc45..24f0b7acf 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,5 +1,6 @@ { "languages": { - "Python": { "language_servers": ["ty", "ruff", "basedpyright"] } + "Python": { "language_servers": ["ty", "ruff", "basedpyright"] }, + "TypeScript": { "language_servers": [] } } } diff --git a/QUESTION_TOOL_BUG_FIX.md b/QUESTION_TOOL_BUG_FIX.md deleted file mode 100644 index f7bfec44f..000000000 --- a/QUESTION_TOOL_BUG_FIX.md +++ /dev/null @@ -1,206 +0,0 @@ -# Question Tool Bug Analysis and Fix - -## Problem - -The question tool is not working because of a mismatch between what the tool sends and what the input provider accepts. - -### Current Flow - -1. **Question tool** (`src/agentpool/tool_impls/question/tool.py`): - - Creates elicitation with schema: `{"type": "string"}` when no response_schema is provided - - Calls `ctx.handle_elicitation(params)` - -2. **AgentContext** (`src/agentpool/agents/context.py:96`): - - Forwards to input provider: `provider.get_elicitation(params)` - -3. **ACPInputProvider** (`src/agentpool_server/opencode_server/input_provider.py:215`): - - Only handles schemas with `enum` field - - Returns `ElicitResult(action="decline")` for plain string schemas - - Never broadcasts question event to client - -### Why It Fails - -```python -# In input_provider.py get_elicitation(): -if isinstance(params, types.ElicitRequestFormParams): - schema = params.requestedSchema - - # Check if schema defines options (enum) - enum_values = schema.get("enum") - if enum_values: - return await self._handle_question_elicitation(params, schema) - - # ... more enum checks ... - -# For other form elicitation, we don't have UI support yet -return types.ElicitResult(action="decline") # <-- THIS IS WHERE IT FAILS -``` - -The tool sends `{"type": "string"}` but the provider only accepts schemas with enum/options. - -## Solutions - -### Option 1: Support Free-Form Text Input (Recommended) - -Modify `ACPInputProvider.get_elicitation()` to handle plain text prompts without enum: - -```python -async def get_elicitation( - self, - params: types.ElicitRequestParams, -) -> types.ElicitResult | types.ErrorData: - """Get user response to elicitation request via OpenCode questions.""" - - # For URL elicitation - if isinstance(params, types.ElicitRequestURLParams): - # ... existing code ... - return types.ElicitResult(action="decline") - - # For form elicitation - if isinstance(params, types.ElicitRequestFormParams): - schema = params.requestedSchema - - # Check if schema defines options (enum) - enum_values = schema.get("enum") - if enum_values: - return await self._handle_question_elicitation(params, schema) - - # Check if it's an array schema with enum items - if schema.get("type") == "array": - items = schema.get("items", {}) - if items.get("enum"): - return await self._handle_question_elicitation(params, schema) - - # NEW: Handle free-form text input - if schema.get("type") == "string": - return await self._handle_text_input_elicitation(params) - - return types.ElicitResult(action="decline") -``` - -Then add a new method: - -```python -async def _handle_text_input_elicitation( - self, - params: types.ElicitRequestFormParams, -) -> types.ElicitResult | types.ErrorData: - """Handle free-form text input via OpenCode input system. - - For prompts without predefined options, we can either: - 1. Use a simple text input (if OpenCode supports it) - 2. Create a single "Other" option that accepts free text - """ - import asyncio - from agentpool_server.opencode_server.models.events import QuestionAskedEvent - from agentpool_server.opencode_server.models.question import ( - QuestionInfo, - QuestionOption, - ) - - question_id = self._generate_permission_id() - - # Create a question with a single "Other (type your answer)" option - question_info = QuestionInfo( - question=params.message, - header=params.message[:12], - options=[ - QuestionOption( - label="Other", - description="Type your answer", - ) - ], - multiple=None, # Single answer expected - ) - - # Create future to wait for answer - future: asyncio.Future[list[list[str]]] = asyncio.get_event_loop().create_future() - - # Store pending question - from agentpool_server.opencode_server.state import PendingQuestion - self.state.pending_questions[question_id] = PendingQuestion( - session_id=self.session_id, - questions=[question_info], - future=future, - tool=None, - ) - - # Broadcast event - event = QuestionAskedEvent.create( - request_id=question_id, - session_id=self.session_id, - questions=[question_info.model_dump(mode="json", by_alias=True)], - ) - await self.state.broadcast_event(event) - - logger.info("Text input question asked", question_id=question_id, message=params.message) - - # Wait for answer - try: - answers = await future - answer = answers[0][0] if answers and answers[0] else "" - - # Return the free-form text - content: dict[str, str] = {"value": answer} - return types.ElicitResult(action="accept", content=content) - except asyncio.CancelledError: - logger.info("Question cancelled", question_id=question_id) - return types.ElicitResult(action="cancel") - except Exception as e: - logger.exception("Question failed", question_id=question_id) - return types.ErrorData(code=-1, message=f"Elicitation failed: {e}") - finally: - # Clean up pending question - self.state.pending_questions.pop(question_id, None) -``` - -### Option 2: Use response_schema Parameter - -Update the question tool to always provide an enum with an "Other" option: - -```python -async def _execute( - self, - ctx: AgentContext, - prompt: str, - response_schema: dict[str, Any] | None = None, -) -> ToolResult: - """Ask the user a clarifying question.""" - from mcp.types import ElicitRequestFormParams, ElicitResult, ErrorData - - # If no schema provided, create one with "Other" option - if response_schema is None: - schema = { - "type": "string", - "enum": ["Other"], # Single option that accepts free text - "x-option-descriptions": { - "Other": "Type your answer" - } - } - else: - schema = response_schema - - params = ElicitRequestFormParams(message=prompt, requestedSchema=schema) - result = await ctx.handle_elicitation(params) - # ... rest of the method ... -``` - -## Recommended Fix - -**Option 1** is better because it: -1. Properly supports free-form text input at the provider level -2. Doesn't require hacky enum workarounds -3. Is more maintainable and clear about intent -4. Can be extended to support other input types in the future - -## Testing - -After implementing the fix, test with: - -```python -async with ClaudeCodeAgent(...) as agent: - async for event in agent.run_stream("Ask me a question using your question tool"): - print(event) -``` - -The question should appear in the OpenCode UI and return the user's answer. diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index c2d7d20a2..7366c7bc4 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -11,31 +11,31 @@ name = "AgentPool" icon = "./icons/agentpool.svg" [agent_servers.agentpool.targets.darwin-aarch64] -archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-darwin-aarch64-1.15.12.zip" +archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-darwin-aarch64-1.15.12.zip" cmd = "./agentpool" args = ["serve-acp"] [agent_servers.agentpool.targets.darwin-x86_64] -archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-darwin-x86_64-1.15.12.zip" +archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-darwin-x86_64-1.15.12.zip" cmd = "./agentpool" args = ["serve-acp"] # [agent_servers.agentpool.targets.linux-aarch64] -# archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-linux-aarch64-1.15.12.zip" +# archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-linux-aarch64-1.15.12.zip" # cmd = "./agentpool" # args = ["serve-acp"] [agent_servers.agentpool.targets.linux-x86_64] -archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-linux-x86_64-1.15.12.zip" +archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-linux-x86_64-1.15.12.zip" cmd = "./agentpool" args = ["serve-acp"] # [agent_servers.agentpool.targets.windows-aarch64] -# archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-windows-aarch64-1.15.12.zip" +# archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-windows-aarch64-1.15.12.zip" # cmd = "./agentpool.exe" # args = ["serve-acp"] [agent_servers.agentpool.targets.windows-x86_64] -archive = "https://github.com/phil65/agentpool/releases/download/v2.9.5/agentpool-windows-x86_64-1.15.12.zip" +archive = "https://github.com/phil65/agentpool/releases/download/v2.9.17/agentpool-windows-x86_64-1.15.12.zip" cmd = "./agentpool.exe" args = ["serve-acp"] diff --git a/docs/examples/round_robin/main.py b/docs/examples/round_robin/main.py index d4d4f77da..04b384b50 100644 --- a/docs/examples/round_robin/main.py +++ b/docs/examples/round_robin/main.py @@ -8,7 +8,7 @@ import os -from agentpool.__main__ import run_command # type: ignore[attr-defined] +from agentpool_cli.run import run_command from agentpool.docs.utils import get_config_path, is_pyodide diff --git a/docs/proposals/unified-mentions.md b/docs/proposals/unified-mentions.md new file mode 100644 index 000000000..4f67c9c5d --- /dev/null +++ b/docs/proposals/unified-mentions.md @@ -0,0 +1,174 @@ +# Proposal: Unified Mention System + +## Problem + +Context references (files, selections, URLs, agent delegations) are represented differently across protocols: + +- **Zed**: `MentionUri` variants (`File`, `Selection`, `Symbol`, `Fetch`, etc.) +- **OpenCode**: `PartInput` variants (`TextPartInput`, `FilePartInput`, `AgentPartInput`, `SubtaskPartInput`) +- **ACP**: `ContentBlock` variants (`TextContentBlock`, `ResourceContentBlock`, `EmbeddedResourceContentBlock`) +- **Internal**: `PathReference`, raw strings, `UserContent` + +Each protocol adapter converts its own mention types to flat text or `UserContent` before passing to agents. This loses structure and prevents smart context management. + +## Proposal + +Define a protocol-agnostic `Mention` type system in `agentpool.messaging.mentions` that all protocols map to and from. Agents receive structured mentions, and a resolution layer expands them to `UserContent` right before the LLM call. + +## Proposed Types + +```python +@dataclass(frozen=True) +class FileMention: + """Reference to a file.""" + path: str + fs: AsyncFileSystem | None = None + mime_type: str | None = None + display_name: str | None = None + +@dataclass(frozen=True) +class DirectoryMention: + """Reference to a directory (may be expanded to file listing or tree).""" + path: str + fs: AsyncFileSystem | None = None + +@dataclass(frozen=True) +class SelectionMention: + """Reference to a specific range within a file.""" + path: str + start_line: int + end_line: int + fs: AsyncFileSystem | None = None + +@dataclass(frozen=True) +class SymbolMention: + """Reference to a named symbol (function, class) in a file.""" + path: str + name: str + start_line: int + end_line: int + +@dataclass(frozen=True) +class UrlMention: + """Reference to a URL to be fetched.""" + url: str + +@dataclass(frozen=True) +class ImageMention: + """Inline image (already resolved).""" + data: str # base64 + mime_type: str | None = None + +@dataclass(frozen=True) +class ResourceMention: + """Reference to an MCP resource (resolved via tool manager).""" + uri: str + server_name: str | None = None + +@dataclass(frozen=True) +class AgentMention: + """Delegation to another agent.""" + agent_name: str + +@dataclass(frozen=True) +class SubtaskMention: + """Structured sub-task request.""" + agent_name: str + prompt: str + description: str | None = None + +@dataclass(frozen=True) +class DiagnosticsMention: + """IDE diagnostics (errors/warnings).""" + include_errors: bool = True + include_warnings: bool = False + +@dataclass(frozen=True) +class GitDiffMention: + """Git diff against a ref.""" + base_ref: str = "main" + +@dataclass(frozen=True) +class TerminalMention: + """Terminal output selection.""" + line_count: int + +Mention = ( + FileMention | DirectoryMention | SelectionMention | SymbolMention + | UrlMention | ImageMention | ResourceMention + | AgentMention | SubtaskMention + | DiagnosticsMention | GitDiffMention | TerminalMention +) +``` + +## Resolution Pipeline + +``` +Protocol Input → Mention[] → resolve(mentions) → UserContent[] → LLM +``` + +A `MentionResolver` converts mentions to `UserContent` right before the LLM call: + +- `FileMention` → read file → `str` (or `BinaryContent` for non-text) +- `DirectoryMention` → list directory → `str` +- `SelectionMention` → read lines → `str` +- `SymbolMention` → read lines → `str` +- `UrlMention` → fetch → `str` or `BinaryContent` +- `ImageMention` → `ImageUrl` +- `ResourceMention` → resolve via MCP → `str` or `BinaryContent` +- `AgentMention` → synthetic instruction text (current behavior) +- `SubtaskMention` → synthetic instruction text (current behavior) +- `DiagnosticsMention` / `GitDiffMention` / `TerminalMention` → resolve from IDE context + +## Protocol Mapping + +| Mention Type | Zed MentionUri | OpenCode PartInput | ACP ContentBlock | +|---------------------|---------------------|---------------------|----------------------| +| `FileMention` | `File` | `FilePartInput` | `ResourceContentBlock` | +| `DirectoryMention` | `Directory` | `FilePartInput` | `ResourceContentBlock` | +| `SelectionMention` | `Selection` | — | `ResourceContentBlock` | +| `SymbolMention` | `Symbol` | — | `ResourceContentBlock` | +| `UrlMention` | `Fetch` | `FilePartInput` | `ResourceContentBlock` | +| `ImageMention` | `PastedImage`/Image | `FilePartInput` | `ImageContentBlock` | +| `ResourceMention` | — | `FilePartInput`+src | `EmbeddedResource` | +| `AgentMention` | — | `AgentPartInput` | — | +| `SubtaskMention` | — | `SubtaskPartInput` | — | +| `DiagnosticsMention`| `Diagnostics` | — | — | +| `GitDiffMention` | `GitDiff` | — | — | +| `TerminalMention` | `TerminalSelection` | — | — | + +## What This Enables + +1. **Smart context management** — knows what each context piece *is*, can prioritize/truncate intelligently +2. **Lazy resolution** — don't read files until needed, cache across turns +3. **Cross-protocol bridging** — a Zed file mention can be forwarded to an OpenCode agent without losing structure +4. **Multi-agent handoff** — structured context travels between agents without re-parsing +5. **Subsumes `PathReference`** — `FileMention`/`DirectoryMention` replace the current `PathReference` with richer semantics + +## Storage & Roundtrip Persistence + +Mentions should survive storage roundtrips. Rather than a separate `mentions` field on `ChatMessage`, the approach is to extend pydantic-ai's content type system: + +```python +# Extend UserContent with mention types +AgentPoolContent = UserContent | Mention + +# UserPromptPart.content becomes list[AgentPoolContent] +# Mentions live inline with text/images, preserving order +``` + +Storage serializes the full content list including unresolved mentions. On LLM call, a resolution boundary converts mentions to `UserContent`: + +``` +Storage → list[AgentPoolContent] → resolve() → list[UserContent] → pydantic-ai +``` + +This keeps mentions as first-class content in the conversation history without forking the message model. The resolution step is the only place that needs to know how to expand mentions. + +## Migration Path + +1. Define mention types in `agentpool.messaging.mentions` +2. Add `MentionResolver` that converts to `UserContent[]` +3. Update `extract_user_prompt_from_parts` to emit `Mention[]` instead of resolving inline +4. Update Zed/ACP converters to emit `Mention[]` +5. Deprecate `PathReference` in favor of `FileMention`/`DirectoryMention` diff --git a/examples/codex_with_mcp_injection.py b/examples/codex_with_mcp_injection.py deleted file mode 100644 index 1bbb6c9f9..000000000 --- a/examples/codex_with_mcp_injection.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Example: Injecting MCP servers into Codex agent programmatically. - -This demonstrates how to inject MCP servers when initializing a Codex agent, -similar to how ToolManagerBridge creates in-process MCP servers for ACP agents. -""" - -from __future__ import annotations - -from codex_adapter import CodexClient, HttpMcpServer, StdioMcpServer, get_text_delta - - -async def example_with_http_mcp_server(): - """Example: Inject an HTTP MCP server (like ToolManagerBridge creates).""" - # Suppose we have a ToolManagerBridge running on port 8000 - # (In practice, you'd start the bridge first and get the URL) - mcp_servers = { - "agentpool-tools": HttpMcpServer( - url="http://localhost:8000/mcp", - # Optional: if the MCP server requires authentication - bearer_token_env_var="AGENTPOOL_MCP_TOKEN", - ), - } - - async with CodexClient(mcp_servers=mcp_servers) as client: - thread = await client.thread_start(cwd="/path/to/project") - print(f"Started thread: {thread.thread.id}") - # Now the Codex agent has access to all tools exposed by the MCP server - async for event in client.turn_stream( - thread.thread.id, "List available tools and show what they can do" - ): - if text := get_text_delta(event): - print(text, end="", flush=True) - print() - - -async def example_with_stdio_mcp_server(): - """Example: Inject a stdio-based MCP server.""" - mcp_servers = { - "bash": StdioMcpServer( - command="npx", - args=["-y", "@openai/codex-shell-tool-mcp"], - ), - } - - async with CodexClient(mcp_servers=mcp_servers) as client: - thread = await client.thread_start(cwd="/tmp") - print(f"Started thread: {thread.thread.id}") - async for event in client.turn_stream(thread.thread.id, "List files in current directory"): - if text := get_text_delta(event): - print(text, end="", flush=True) - print() - - -async def example_with_multiple_mcp_servers(): - """Example: Inject multiple MCP servers at once.""" - mcp_servers = { - # HTTP-based MCP server (e.g., ToolManagerBridge) - "tools": HttpMcpServer( - url="http://localhost:8000/mcp", - http_headers={"X-Custom-Header": "value"}, - ), - # Stdio-based MCP server - "bash": StdioMcpServer( - command="npx", - args=["-y", "@openai/codex-shell-tool-mcp"], - env={"DEBUG": "1"}, # Optional environment variables - ), - # Another HTTP MCP server (maybe from composio or another service) - "composio": HttpMcpServer( - url="https://api.composio.dev/mcp", - bearer_token_env_var="COMPOSIO_API_KEY", - ), - } - - async with CodexClient(mcp_servers=mcp_servers) as client: - thread = await client.thread_start(cwd="/path/to/project") - print(f"Started thread with {len(mcp_servers)} MCP servers: {thread.thread.id}") - # The agent now has access to tools from all three MCP servers - prompt = "Show me all available tools and their sources" - async for event in client.turn_stream(thread.thread.id, prompt): - if text := get_text_delta(event): - print(text, end="", flush=True) - print() - - -async def example_integration_with_agentpool(): - """Example: How this would integrate with AgentPool's ToolManagerBridge. - - This is conceptual - shows how you'd use a ToolManagerBridge's MCP server - config with a Codex agent. - """ - # In AgentPool, you'd have something like: - # bridge = ToolManagerBridge(node, config) - # await bridge.start() - # mcp_config = bridge.get_claude_mcp_server_config() - # Simulating what that would return: - mcp_config = {"agentpool-tools": {"type": "http", "url": "http://localhost:8765/mcp"}} - # Convert to CodexClient format - mcp_servers = { - name: HttpMcpServer(url=config["url"]) - for name, config in mcp_config.items() - if config["type"] == "http" - } - - async with CodexClient(mcp_servers=mcp_servers) as client: - thread = await client.thread_start() - print(f"Codex agent with AgentPool tools: {thread.thread.id}") - # Now the Codex agent can use tools from AgentPool! - prompt = "Use the available tools to analyze this project" - async for event in client.turn_stream(thread.thread.id, prompt): - if text := get_text_delta(event): - print(text, end="", flush=True) - print() - - -if __name__ == "__main__": - # Run the examples (comment out the ones you don't want to run) - - # Example 1: HTTP MCP server (like ToolManagerBridge) - # asyncio.run(example_with_http_mcp_server()) - - # Example 2: Stdio MCP server - # asyncio.run(example_with_stdio_mcp_server()) - - # Example 3: Multiple MCP servers - # asyncio.run(example_with_multiple_mcp_servers()) - - # Example 4: Integration with AgentPool - # asyncio.run(example_integration_with_agentpool()) - - print("Examples ready to run - uncomment the ones you want to try!") diff --git a/pyproject.toml b/pyproject.toml index 43a9b412b..66dcbb9f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "agentpool" -version = "2.9.5" +version = "2.9.17" description = "Pydantic-AI based Multi-Agent Framework with YAML-based Agents, Teams, Workflows & Extended ACP / AGUI integration" readme = "README.md" requires-python = ">=3.13" @@ -33,8 +33,10 @@ classifiers = [ dependencies = [ "alembic>=1.16.5", "anyenv[httpx]>=0.3.0", + "bashkit>=0.1.11", "clawd-code-sdk>=0.1.36", # "clawd-code-sdk@git+https://github.com/phil65/claude-agent-sdk-python.git@efbaa798", + "codexed>=0.0.1", "docler>=1.0.3", "docstring-parser>=0.17.0", "epregistry", @@ -137,7 +139,7 @@ markitdown = [ ] # MarkItDown Media Converter mcp-discovery = [ "fastembed>=0.7.4; python_version < '3.14'", - "lancedb>=0.26.0; python_version < '3.14'", + "lancedb==0.30.0; python_version < '3.14'", "pyarrow>=19.0.0; python_version < '3.14'", ] # MCP Discovery Toolset with semantic search mcp_run = ["mcpx-py>=0.7.0"] # MCP.run Toolset @@ -154,7 +156,7 @@ dev = [ "check-jsonschema>=0.35.0", "devtools", "fastembed>=0.7.4; python_version < '3.14'", - "lancedb>=0.26.0; python_version < '3.14'", + "lancedb==0.30.0; python_version < '3.14'", "openapi_spec_validator", "pyinstaller>=6.17.0", "pyreadline3", @@ -167,6 +169,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist", "syrupy>=4.0.0", + "ty>=0.0.23", ] benchmark = ["pyinstrument"] docs = [ @@ -256,26 +259,6 @@ disable_error_code = [ module = ["fsspec.*"] ignore_missing_imports = true -[tool.pyright] -venvPath = "." -venv = ".venv" -pythonVersion = "3.13" -pythonPlatform = "All" -typeCheckingMode = "basic" -deprecateTypingAliases = true -reportMissingTypeStubs = false -reportUnusedCallResult = false -reportUnknownVariableType = false -reportAny = false -reportImplicitOverride = false -reportUnusedFunction = false -reportImplicitStringConcatenation = false -reportIgnoreCommentWithoutRule = false -reportUnannotatedClassAttribute = false -reportSelfClsParameterName = false -reportPrivateImportUsage = false -reportUnusedExpression = false - [tool.pytest] addopts = ["-m", "not slow and not acp_snapshot"] filterwarnings = [ @@ -443,6 +426,10 @@ max-complexity = 15 [tool.ruff.format] preview = true +[tool.ty] +[tool.ty.src] +exclude = [] + [tool.ty.environment] python-version = "3.13" python-platform = "all" @@ -457,6 +444,29 @@ division-by-zero = "warn" [tool.uv] default-groups = ["dev", "lint", "docs"] constraint-dependencies = ["extism-sys<1.13.0"] +# exclude-newer = "1 day" + +# [tool.uv.exclude-newer-package] # exclude self-maintained +# codexed = "0 days" +# clawd-code-sdk = "0 days" +# docler = "0 days" +# epregistry = "0 days" +# anyenv = "0 days" +# exxec = "0 days" +# mknodes = "0 days" +# tokonomics = "0 days" +# toprompt = "0 days" +# yamling = "0 days" +# sublime-search = "0 days" +# ripgrep-rs = "0 days" +# slashed = "0 days" +# searchly = "0 days" +# schemez = "0 days" +# upathtools = "0 days" +# promptantic = "0 days" +# llmling-models = "0 days" +# jinjarope = "0 days" +# evented = "0 days" [tool.uv.build-backend] module-name = [ @@ -469,7 +479,8 @@ module-name = [ "agentpool_server", "agentpool_toolsets", "acp", - "codex_adapter", + "opencode_sdk", + "pi_sdk", ] wheel-exclude = [ ".mypy_cache/**", diff --git a/schema/config-schema.json b/schema/config-schema.json index aa7c8841f..5efec0ffb 100644 --- a/schema/config-schema.json +++ b/schema/config-schema.json @@ -1579,13 +1579,19 @@ "description": "Agent definition configuration.", "properties": { "description": { - "description": "Description of the agent.", - "title": "Description", + "description": "A brief description of the agent's purpose.", + "examples": [ + "QA Assistant" + ], + "title": "Agent Description", "type": "string" }, "prompt": { - "description": "Prompt for the agent.", - "title": "Prompt", + "description": "The prompt to use for this agent.", + "examples": [ + "Do XY" + ], + "title": "Agent Prompt", "type": "string" }, "tools": { @@ -1601,8 +1607,11 @@ } ], "default": null, - "description": "List of tools the agent can use.", - "title": "Tools" + "description": "The tools this agent has access to.", + "examples": [ + "Bash" + ], + "title": "Agent Tools" }, "model": { "anyOf": [ @@ -1615,13 +1624,19 @@ ], "type": "string" }, + { + "type": "string" + }, { "type": "null" } ], "default": null, - "description": "Model to use for the agent.", - "title": "Model" + "description": "The model to use for this agent.", + "examples": [ + "sonnet" + ], + "title": "Agent Model" }, "memory": { "anyOf": [ @@ -1638,8 +1653,11 @@ } ], "default": null, - "description": "Memory type for the agent.", - "title": "Memory" + "examples": [ + "user", + "project" + ], + "title": "Agent Memory" }, "disallowed_tools": { "anyOf": [ @@ -1654,9 +1672,21 @@ } ], "default": null, - "description": "List of tools the agent cannot use.", + "description": "Tools this agent is not allowed to use.", + "examples": [ + "Bash" + ], "title": "Disallowed Tools" }, + "criticalSystemReminder_EXPERIMENTAL": { + "default": null, + "description": "Critical system reminder message to display to the user.", + "title": "Critical System Reminder", + "type": [ + "string", + "null" + ] + }, "skills": { "anyOf": [ { @@ -1670,18 +1700,94 @@ } ], "default": null, - "description": "List of skills the agent can use.", + "description": "Skills this agent has.", + "examples": [ + "my-skill" + ], "title": "Skills" }, "max_turns": { "default": null, - "description": "Maximum number of turns the agent can take.", + "description": "Maximum number of agentic turns (API round-trips) before stopping.", "title": "Max Turns", "type": [ "integer", "null" ] }, + "background": { + "default": null, + "description": "Whether this agent runs in the background.", + "title": "Run in Background", + "type": [ + "boolean", + "null" + ] + }, + "effort": { + "anyOf": [ + { + "enum": [ + "low", + "medium", + "high", + "max" + ], + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Effort level for thinking depth.", + "examples": [ + "high" + ], + "title": "Reasoning effort" + }, + "permission_mode": { + "anyOf": [ + { + "enum": [ + "default", + "acceptEdits", + "plan", + "bypassPermissions" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Permission mode for this agent.", + "examples": [ + "bypassPermissions" + ], + "title": "Permission Mode" + }, + "isolation": { + "anyOf": [ + { + "const": "worktree", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Isolation mode. ``\"worktree\"`` runs the agent in a separate git worktree.", + "examples": [ + "worktree" + ], + "title": "Isolation Mode" + }, "mcp_servers": { "anyOf": [ { @@ -1715,15 +1821,6 @@ "default": null, "description": "Configuration for MCP servers.", "title": "Mcp Servers" - }, - "background": { - "default": null, - "description": "Run as background agent.", - "title": "Background", - "type": [ - "boolean", - "null" - ] } }, "required": [ diff --git a/scripts/add_frontmatter.py b/scripts/add_frontmatter.py index ebc76b0d3..3acf89e7d 100644 --- a/scripts/add_frontmatter.py +++ b/scripts/add_frontmatter.py @@ -79,7 +79,7 @@ def extract_page_metadata(python_file: Path) -> dict[str, dict[str, Any]]: md_path = right.args[0].value assert isinstance(md_path, str) if page_metadata: - metadata[md_path] = page_metadata # pyright: ignore[reportArgumentType] + metadata[md_path] = page_metadata break # Found the template, move to next function return metadata diff --git a/scripts/build_mcp_registry_index.py b/scripts/build_mcp_registry_index.py index cae02299b..217a9f46e 100644 --- a/scripts/build_mcp_registry_index.py +++ b/scripts/build_mcp_registry_index.py @@ -190,9 +190,9 @@ async def main(output_path: Path) -> None: test_table = db.create_table("servers", table) query_vec = next(iter(model.embed(["github repository issues"]))).tolist() results = test_table.search(query_vec).limit(3).to_arrow() - for i in range(len(results)): - name = results["name"][i].as_py() - desc = results["description"][i].as_py()[:60] + for row in results.to_pylist(): + name = row["name"] + desc = row["description"][:60] print(f" - {name}: {desc}...") diff --git a/scripts/reorder_nav.py b/scripts/reorder_nav.py index 3d5a86163..53f4fbf13 100644 --- a/scripts/reorder_nav.py +++ b/scripts/reorder_nav.py @@ -114,7 +114,7 @@ def extract_nav_item_info(nav_item: Tag) -> tuple[str | None, str | None]: href = "index" assert isinstance(href, str) # Clean up href - remove leading/trailing slashes and trailing index.html - href = href.strip("/") # pyright: ignore[reportAttributeAccessIssue] + href = href.strip("/") if href.endswith("/"): href = href[:-1] if href.endswith("index.html"): @@ -186,7 +186,7 @@ def get_sort_key(nav_item: Tag) -> tuple[int, str]: current_parts = Path(current_page_path).parts if current_page_path else () # Clean up href first - href = href.strip("/") # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess] + href = href.strip("/") # Resolve relative paths based on current page context # This handles: "../foo", "./foo", "foo" (all relative to current page) diff --git a/scripts/test_acp_mcp_capabilities.py b/scripts/test_acp_mcp_capabilities.py index a1c47eb45..e15a43d15 100644 --- a/scripts/test_acp_mcp_capabilities.py +++ b/scripts/test_acp_mcp_capabilities.py @@ -12,10 +12,11 @@ import asyncio from dataclasses import dataclass import sys +from typing import Any from acp.client.implementations import NoOpClient from acp.schema import InitializeRequest -from acp.schema.capabilities import ClientCapabilities, FileSystemCapability +from acp.schema.capabilities import ClientCapabilities, FileSystemCapabilities from acp.schema.common import Implementation from acp.stdio import spawn_agent_process @@ -53,7 +54,7 @@ async def test_agent_capabilities(config: AgentTestConfig) -> dict: Returns: Dict with agent info and capabilities """ - result = { + result: dict[str, Any] = { "name": config.name, "command": config.command, "status": "unknown", @@ -75,7 +76,7 @@ async def test_agent_capabilities(config: AgentTestConfig) -> dict: client_info=Implementation(title="Tester", name="cap-test", version="0.1.0"), client_capabilities=ClientCapabilities( terminal=True, - fs=FileSystemCapability(read_text_file=True, write_text_file=True), + fs=FileSystemCapabilities(read_text_file=True, write_text_file=True), ), ) diff --git a/scripts/tts_performance.py b/scripts/tts_performance.py index 7ca06d1ba..6b05e4426 100644 --- a/scripts/tts_performance.py +++ b/scripts/tts_performance.py @@ -74,9 +74,10 @@ async def run_single(mode: TTSMode, provider: TTSProvider = "openai") -> float: start_time = time.perf_counter() async with agent: + ctx = agent.get_context() async for event in agent.run_stream(prompt): - await print_handler(None, event) # type: ignore - await handler(None, event) # type: ignore + await print_handler(ctx, event) + await handler(ctx, event) # pyright: ignore[reportArgumentType] stream_time = time.perf_counter() - start_time print(f"\n\nStream completed in: {stream_time:.2f}s") @@ -112,8 +113,9 @@ async def run_sequential(mode: TTSMode, provider: TTSProvider = "openai") -> Non start_time = time.perf_counter() async for event in agent.run_stream(prompt): - await print_handler(None, event) # type: ignore - await handler(None, event) # type: ignore + ctx = agent.get_context() + await print_handler(ctx, event) + await handler(ctx, event) # pyright: ignore[reportArgumentType] stream_time = time.perf_counter() - start_time print(f"\n\nStream completed in: {stream_time:.2f}s") @@ -131,7 +133,7 @@ async def compare_all_modes(provider: TTSProvider = "openai") -> None: for mode in ("sync_sentence", "sync_run", "async_queue", "async_cancel"): input(f"\nPress Enter to test mode: {mode}") - results[mode] = await run_single(mode, provider) # type: ignore + results[mode] = await run_single(mode, provider) await anyio.sleep(2) print("\n" + "=" * 60) @@ -192,10 +194,8 @@ async def main(): } if choice in mode_map: - provider = input("Provider (openai/edge) [openai]: ").strip().lower() or "openai" - if provider not in ("openai", "edge"): - provider = "openai" - await run_single(mode_map[choice], provider) # type: ignore + provider = input("Provider (openai/edge) [openai]: ").strip().lower() + await run_single(mode_map[choice], provider="openai" if provider == "openai" else "edge") elif choice == "a": await compare_all_modes("openai") elif choice == "b": diff --git a/src/acp/__init__.py b/src/acp/__init__.py index 1ef6be18c..8b68696e6 100644 --- a/src/acp/__init__.py +++ b/src/acp/__init__.py @@ -11,6 +11,7 @@ from acp.terminal_handle import TerminalHandle from acp.tool_call_state import ToolCallState from acp.schema import ( + AgentAuthCapabilities, AuthCapabilities, AuthEnvVar, AuthMethod, @@ -19,11 +20,29 @@ AuthMethodTerminal, AuthenticateRequest, AuthenticateResponse, + BooleanPropertySchema, CancelNotification, CreateTerminalRequest, ClientCapabilities, + ElicitationAcceptAction, + ElicitationAction, + ElicitationCancelAction, + ElicitationCapabilities, + ElicitationCompleteNotification, + ElicitationContentValue, + ElicitationDeclineAction, + ElicitationFormCapabilities, + ElicitationFormMode, + ElicitationMode, + ElicitationPropertySchema, + ElicitationRequest, + ElicitationResponse, + ElicitationSchema, + ElicitationUrlCapabilities, + ElicitationUrlMode, + EnumOption, SessionMode, - FileSystemCapability, + FileSystemCapabilities, AgentMessageChunk, UserMessageChunk, TextContentBlock, @@ -36,6 +55,10 @@ HttpMcpServer, CreateTerminalResponse, InitializeRequest, + IntegerPropertySchema, + LogoutCapabilities, + LogoutRequest, + LogoutResponse, PlanEntryPriority, PlanEntryStatus, InitializeResponse, @@ -44,6 +67,9 @@ LoadSessionRequest, LoadSessionResponse, ModelInfo, + MultiSelectItems, + MultiSelectPropertySchema, + NumberPropertySchema, AllowedOutcome, DeniedOutcome, NewSessionRequest, @@ -57,11 +83,13 @@ RequestPermissionRequest, RequestPermissionResponse, SessionModelState, + StringFormatLiteral, + StringPropertySchema, SessionNotification, SetSessionModelRequest, SetSessionModelResponse, - StopSessionRequest, - StopSessionResponse, + CloseSessionRequest, + CloseSessionResponse, SetSessionModeRequest, SetSessionModeResponse, TerminalOutputRequest, @@ -70,6 +98,8 @@ WaitForTerminalExitResponse, WriteTextFileRequest, WriteTextFileResponse, + TitledMultiSelectItems, + UntitledMultiSelectItems, PermissionOption, PROTOCOL_VERSION, AgentMethod, @@ -112,7 +142,11 @@ "AgentMethod", "ClientMethod", # auth + "AgentAuthCapabilities", "AuthCapabilities", + "LogoutCapabilities", + "LogoutRequest", + "LogoutResponse", "AuthEnvVar", "AuthMethod", "AuthMethodAgent", @@ -147,6 +181,33 @@ "LoadSessionResponse", "AuthenticateRequest", "AuthenticateResponse", + # elicitation + "BooleanPropertySchema", + "ElicitationAcceptAction", + "ElicitationAction", + "ElicitationCancelAction", + "ElicitationCapabilities", + "ElicitationCompleteNotification", + "ElicitationContentValue", + "ElicitationDeclineAction", + "ElicitationFormCapabilities", + "ElicitationFormMode", + "ElicitationMode", + "ElicitationPropertySchema", + "ElicitationRequest", + "ElicitationResponse", + "ElicitationSchema", + "ElicitationUrlCapabilities", + "ElicitationUrlMode", + "EnumOption", + "IntegerPropertySchema", + "MultiSelectItems", + "MultiSelectPropertySchema", + "NumberPropertySchema", + "StringFormatLiteral", + "StringPropertySchema", + "TitledMultiSelectItems", + "UntitledMultiSelectItems", "PromptRequest", "ClientCapabilities", "SessionModeState", @@ -164,8 +225,8 @@ "SetSessionModeRequest", "SetSessionModeResponse", # stop session - "StopSessionRequest", - "StopSessionResponse", + "CloseSessionRequest", + "CloseSessionResponse", # model types "ModelInfo", "SessionModelState", @@ -194,7 +255,7 @@ "run_agent", "connect_to_agent", # split protocols - "FileSystemCapability", + "FileSystemCapabilities", # stdio helper "stdio_streams", # transport diff --git a/src/acp/agent/acp_agent_api.py b/src/acp/agent/acp_agent_api.py index b947c2192..3eb96a799 100644 --- a/src/acp/agent/acp_agent_api.py +++ b/src/acp/agent/acp_agent_api.py @@ -12,6 +12,7 @@ InitializeRequest, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ResumeSessionRequest, @@ -32,6 +33,7 @@ InitializeResponse, ListSessionsResponse, LoadSessionResponse, + LogoutResponse, NewSessionResponse, PromptResponse, ResumeSessionResponse, @@ -199,15 +201,20 @@ async def set_session_config_option( self, session_id: str, config_id: str, - value: str, + value: str | bool, ) -> SetSessionConfigOptionResponse | None: - """Set a session configuration option.""" - request = SetSessionConfigOptionRequest( - session_id=session_id, - config_id=config_id, - value=value, # pyright: ignore[reportCallIssue] + """Set a session configuration option. + + Args: + session_id: The session ID. + config_id: The config option ID. + value: String value ID for select options, or bool for boolean options. + """ + type_field = "boolean" if isinstance(value, bool) else None + req = SetSessionConfigOptionRequest( + session_id=session_id, config_id=config_id, value=value, type=type_field ) - return await self.connection.set_session_config_option(request) + return await self.connection.set_session_config_option(req) async def authenticate( self, @@ -217,6 +224,11 @@ async def authenticate( request = AuthenticateRequest(method_id=method_id) return await self.connection.authenticate(request) + async def logout(self) -> LogoutResponse | None: + """Log out of the current authenticated state.""" + request = LogoutRequest() + return await self.connection.logout(request) + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: """Call an extension method on the agent.""" return await self.connection.ext_method(method, params) diff --git a/src/acp/agent/acp_requests.py b/src/acp/agent/acp_requests.py index dcead4613..bea3df559 100644 --- a/src/acp/agent/acp_requests.py +++ b/src/acp/agent/acp_requests.py @@ -54,6 +54,7 @@ async def read_text_file( *, limit: int | None = None, line: int | None = None, + metadata: dict[str, str] | None = None, ) -> str: """Read text content from a file. @@ -61,17 +62,31 @@ async def read_text_file( path: File path to read limit: Maximum number of lines to read line: Line number to start reading from (1-based) + metadata: Optional metadata to include with the request Returns: File content as string """ - request = ReadTextFileRequest(session_id=self.id, path=path, limit=limit, line=line) + request = ReadTextFileRequest( + session_id=self.id, + path=path, + limit=limit, + line=line, + field_meta=metadata, + ) response = await self.client.read_text_file(request) return response.content - async def write_text_file(self, path: str, content: str) -> None: + async def write_text_file( + self, path: str, content: str, metadata: dict[str, str] | None = None + ) -> None: """Write text content to a file.""" - request = WriteTextFileRequest(session_id=self.id, path=path, content=content) + request = WriteTextFileRequest( + session_id=self.id, + path=path, + content=content, + field_meta=metadata, + ) await self.client.write_text_file(request) async def create_terminal( @@ -82,6 +97,7 @@ async def create_terminal( cwd: str | None = None, env: dict[str, str] | None = None, output_byte_limit: int | None = None, + metadata: dict[str, str] | None = None, ) -> TerminalHandle: """Create a new terminal session. @@ -92,6 +108,7 @@ async def create_terminal( cwd: Working directory for terminal env: Environment variables for terminal output_byte_limit: Maximum bytes to capture from output + metadata: Optional metadata to include with the request """ request = CreateTerminalRequest( session_id=self.id, @@ -102,28 +119,61 @@ async def create_terminal( cwd=cwd, env=[EnvVariable(name=k, value=v) for k, v in (env or {}).items()], output_byte_limit=output_byte_limit, + field_meta=metadata, ) response = await self.client.create_terminal(request) return TerminalHandle(terminal_id=response.terminal_id, requests=self) - async def terminal_output(self, terminal_id: str) -> TerminalOutputResponse: + async def terminal_output( + self, + terminal_id: str, + metadata: dict[str, str] | None = None, + ) -> TerminalOutputResponse: """Get output from a terminal session.""" - request = TerminalOutputRequest(session_id=self.id, terminal_id=terminal_id) + request = TerminalOutputRequest( + session_id=self.id, + terminal_id=terminal_id, + field_meta=metadata, + ) return await self.client.terminal_output(request) - async def wait_for_terminal_exit(self, terminal_id: str) -> WaitForTerminalExitResponse: + async def wait_for_terminal_exit( + self, + terminal_id: str, + metadata: dict[str, str] | None = None, + ) -> WaitForTerminalExitResponse: """Wait for a terminal to exit.""" - request = WaitForTerminalExitRequest(session_id=self.id, terminal_id=terminal_id) + request = WaitForTerminalExitRequest( + session_id=self.id, + terminal_id=terminal_id, + field_meta=metadata, + ) return await self.client.wait_for_terminal_exit(request) - async def kill_terminal(self, terminal_id: str) -> None: + async def kill_terminal( + self, + terminal_id: str, + metadata: dict[str, str] | None = None, + ) -> None: """Kill a terminal session.""" - request = KillTerminalCommandRequest(session_id=self.id, terminal_id=terminal_id) + request = KillTerminalCommandRequest( + session_id=self.id, + terminal_id=terminal_id, + field_meta=metadata, + ) await self.client.kill_terminal(request) - async def release_terminal(self, terminal_id: str) -> None: + async def release_terminal( + self, + terminal_id: str, + metadata: dict[str, str] | None = None, + ) -> None: """Release a terminal session.""" - request = ReleaseTerminalRequest(session_id=self.id, terminal_id=terminal_id) + request = ReleaseTerminalRequest( + session_id=self.id, + terminal_id=terminal_id, + field_meta=metadata, + ) await self.client.release_terminal(request) async def run_command( @@ -135,6 +185,7 @@ async def run_command( env: dict[str, str] | None = None, output_byte_limit: int | None = None, timeout_seconds: int | None = None, + metadata: dict[str, str] | None = None, ) -> tuple[str, int | None]: """Execute a shell command and return output and exit code. @@ -148,6 +199,7 @@ async def run_command( env: Environment variables for command execution output_byte_limit: Maximum bytes to capture from output timeout_seconds: Command timeout in seconds + metadata: Metadata for the requests Returns: Tuple of (output, exit_code) @@ -158,26 +210,27 @@ async def run_command( cwd=cwd, env=env, output_byte_limit=output_byte_limit, + metadata=metadata, ) terminal_id = terminal_handle.terminal_id try: if timeout_seconds: # Wait for completion (with optional timeout) try: - coro = self.wait_for_terminal_exit(terminal_id) + coro = self.wait_for_terminal_exit(terminal_id, metadata=metadata) exit_result = await asyncio.wait_for(coro, timeout=timeout_seconds) except TimeoutError: # Kill on timeout and get partial output - await self.kill_terminal(terminal_id) - output_response = await self.terminal_output(terminal_id) + await self.kill_terminal(terminal_id, metadata=metadata) + output_response = await self.terminal_output(terminal_id, metadata=metadata) return output_response.output, None else: - exit_result = await self.wait_for_terminal_exit(terminal_id) + exit_result = await self.wait_for_terminal_exit(terminal_id, metadata=metadata) - output_response = await self.terminal_output(terminal_id) + output_response = await self.terminal_output(terminal_id, metadata=metadata) return output_response.output, exit_result.exit_code finally: # Always release terminal - await self.release_terminal(terminal_id) + await self.release_terminal(terminal_id, metadata=metadata) async def request_permission( self, @@ -186,6 +239,7 @@ async def request_permission( title: str | None = None, raw_input: dict[str, Any] | None = None, options: Sequence[PermissionOption] | None = None, + metadata: dict[str, str] | None = None, ) -> RequestPermissionResponse: """Request permission from user before executing a tool call. @@ -194,6 +248,7 @@ async def request_permission( title: Human-readable description of the operation raw_input: The raw input parameters for the tool call options: Available permission options (defaults to allow/reject once) + metadata: Metadata to include with the request Returns: Permission response with user's decision @@ -205,5 +260,10 @@ async def request_permission( ] tool_call = ToolCall(tool_call_id=tool_call_id, title=title, raw_input=raw_input) - request = RequestPermissionRequest(session_id=self.id, tool_call=tool_call, options=options) + request = RequestPermissionRequest( + session_id=self.id, + tool_call=tool_call, + options=options, + field_meta=metadata, + ) return await self.client.request_permission(request) diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index 93da9c59c..d6dc99ce2 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -15,6 +15,7 @@ from acp.schema import ( AuthenticateRequest, CancelNotification, + CloseSessionRequest, CreateTerminalRequest, CreateTerminalResponse, InitializeRequest, @@ -22,6 +23,7 @@ KillTerminalCommandResponse, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ReadTextFileRequest, @@ -34,7 +36,6 @@ SetSessionConfigOptionRequest, SetSessionModelRequest, SetSessionModeRequest, - StopSessionRequest, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest, @@ -42,6 +43,9 @@ WriteTextFileRequest, WriteTextFileResponse, ) +from acp.schema.elicitation import ( + ElicitationResponse, +) from acp.task import DebuggingMessageStateStore @@ -54,22 +58,18 @@ from acp.connection import StreamObserver from acp.schema import ( AgentMethod, + AgentResponse, CreateTerminalRequest, - InitializeResponse, KillTerminalCommandRequest, - ListSessionsResponse, - LoadSessionResponse, - NewSessionResponse, - PromptResponse, ReadTextFileRequest, ReleaseTerminalRequest, RequestPermissionRequest, SessionNotification, - StopSessionResponse, TerminalOutputRequest, WaitForTerminalExitRequest, WriteTextFileRequest, ) + from acp.schema.elicitation import ElicitationCompleteNotification, ElicitationRequest log = structlog.get_logger(__name__) @@ -196,6 +196,17 @@ async def kill_terminal( resp = await self._conn.send_request("terminal/kill", dct) return KillTerminalCommandResponse.model_validate(resp) + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: + """Request structured user input from the client.""" + dct = params.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) + resp = await self._conn.send_request("session/elicitation", dct) + return ElicitationResponse.model_validate(resp) + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: + """Notify the client that a URL-based elicitation has completed.""" + dct = params.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) + await self._conn.send_notification("session/elicitation/complete", dct) + async def close(self) -> None: """Close the connection.""" await self._conn.close() @@ -215,16 +226,7 @@ async def _agent_handler( # noqa: PLR0911 method: AgentMethod | str, params: dict[str, Any] | None, is_notification: bool, -) -> ( - NewSessionResponse - | InitializeResponse - | PromptResponse - | LoadSessionResponse - | ListSessionsResponse - | StopSessionResponse - | dict[str, Any] - | None -): +) -> AgentResponse | dict[str, Any] | None: """Handle an agent request.""" match method: case "initialize": @@ -241,11 +243,7 @@ async def _agent_handler( # noqa: PLR0911 return await agent.list_sessions(list_request) case "session/set_mode": set_mode_request = SetSessionModeRequest.model_validate(params) - return ( - session_resp.model_dump(by_alias=True, exclude_none=True) - if (session_resp := await agent.set_session_mode(set_mode_request)) - else {} - ) + return await agent.set_session_mode(set_mode_request) case "session/prompt": prompt_request = PromptRequest.model_validate(params) return await agent.prompt(prompt_request) @@ -255,29 +253,23 @@ async def _agent_handler( # noqa: PLR0911 return None case "session/set_model": set_model_request = SetSessionModelRequest.model_validate(params) - return ( - model_result.model_dump(by_alias=True, exclude_none=True) - if (model_result := await agent.set_session_model(set_model_request)) - else {} - ) - case "session/stop": - stop_request = StopSessionRequest.model_validate(params) - return await agent.stop_session(stop_request) + return await agent.set_session_model(set_model_request) + case "session/close": + stop_request = CloseSessionRequest.model_validate(params) + return await agent.close_session(stop_request) case "session/set_config_option": set_config_request = SetSessionConfigOptionRequest.model_validate(params) - return ( - config_result.model_dump(by_alias=True, exclude_none=True) - if (config_result := await agent.set_session_config_option(set_config_request)) - else {} - ) + return await agent.set_session_config_option(set_config_request) case "authenticate": - p = AuthenticateRequest.model_validate(params) - result = await agent.authenticate(p) - return result.model_dump(by_alias=True, exclude_none=True) if result else {} + auth_request = AuthenticateRequest.model_validate(params) + return await agent.authenticate(auth_request) + case "logout": + logout_request = LogoutRequest.model_validate(params) + return await agent.logout(logout_request) case str() if method.startswith("_") and is_notification: await agent.ext_notification(method[1:], params or {}) return None case str() if method.startswith("_"): return await agent.ext_method(method[1:], params or {}) - case _: - raise RequestError.method_not_found(method) + case _ as unknown_method: + raise RequestError.method_not_found(unknown_method) diff --git a/src/acp/agent/implementations/debug_server/debug_server.py b/src/acp/agent/implementations/debug_server/debug_server.py index 1728b9411..710d941da 100644 --- a/src/acp/agent/implementations/debug_server/debug_server.py +++ b/src/acp/agent/implementations/debug_server/debug_server.py @@ -170,8 +170,6 @@ async def _create_notification_update( # noqa: PLR0911 return AvailableCommandsUpdate(available_commands=commands) case "mode_update": return CurrentModeUpdate(current_mode_id=data.get("mode_id", "debug")) - # case "model_update": - # return CurrentModelUpdate(current_model_id=data.get("model_id", "None")) case _: raise ValueError(f"Unknown notification type: {notification_type}") diff --git a/src/acp/agent/implementations/debug_server/mock_agent.py b/src/acp/agent/implementations/debug_server/mock_agent.py index 2efd47efc..a8272d759 100644 --- a/src/acp/agent/implementations/debug_server/mock_agent.py +++ b/src/acp/agent/implementations/debug_server/mock_agent.py @@ -13,18 +13,18 @@ from acp.agent.protocol import Agent from acp.schema import ( AuthenticateResponse, + CloseSessionResponse, CreateTerminalResponse, - # CurrentModelUpdate, ForkSessionResponse, InitializeResponse, ListSessionsResponse, LoadSessionResponse, + LogoutResponse, NewSessionResponse, PromptResponse, ReadTextFileResponse, ResumeSessionResponse, SessionInfo, - StopSessionResponse, WriteTextFileResponse, ) @@ -35,17 +35,18 @@ from acp.schema import ( AuthenticateRequest, CancelNotification, + CloseSessionRequest, CreateTerminalRequest, ForkSessionRequest, InitializeRequest, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ReadTextFileRequest, ResumeSessionRequest, SetSessionConfigOptionRequest, - StopSessionRequest, WriteTextFileRequest, ) @@ -108,6 +109,10 @@ async def authenticate(self, params: AuthenticateRequest) -> AuthenticateRespons """Mock authentication - always succeeds.""" return AuthenticateResponse() + async def logout(self, params: LogoutRequest) -> LogoutResponse | None: + """Mock logout - always succeeds.""" + return LogoutResponse() + async def read_text_file(self, params: ReadTextFileRequest) -> ReadTextFileResponse: """Mock file reading.""" mock_content = MOCK_FILE.format(path=params.path) @@ -166,9 +171,9 @@ async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionRes self.debug_state.active_session_id = params.session_id return ResumeSessionResponse() - async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: + async def close_session(self, params: CloseSessionRequest) -> CloseSessionResponse: """Mock stop session.""" self.debug_state.sessions.pop(params.session_id, None) if self.debug_state.active_session_id == params.session_id: self.debug_state.active_session_id = None - return StopSessionResponse() + return CloseSessionResponse() diff --git a/src/acp/agent/implementations/testing.py b/src/acp/agent/implementations/testing.py index ca199749f..6808dcc7c 100644 --- a/src/acp/agent/implementations/testing.py +++ b/src/acp/agent/implementations/testing.py @@ -14,10 +14,11 @@ ) from acp.schema import ( AuthenticateResponse, + CloseSessionResponse, ForkSessionResponse, ListSessionsResponse, + LogoutResponse, ResumeSessionResponse, - StopSessionResponse, ) @@ -26,16 +27,17 @@ from acp.schema import ( AuthenticateRequest, CancelNotification, + CloseSessionRequest, ForkSessionRequest, InitializeRequest, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ResumeSessionRequest, SetSessionConfigOptionRequest, SetSessionModelRequest, - StopSessionRequest, ) @@ -64,6 +66,9 @@ async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: return AuthenticateResponse() + async def logout(self, params: LogoutRequest) -> LogoutResponse | None: + return LogoutResponse() + async def prompt(self, params: PromptRequest) -> PromptResponse: self.prompts.append(params) return PromptResponse(stop_reason="end_turn") @@ -97,5 +102,5 @@ async def fork_session(self, params: ForkSessionRequest) -> ForkSessionResponse: async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionResponse: return ResumeSessionResponse() - async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: - return StopSessionResponse() + async def close_session(self, params: CloseSessionRequest) -> CloseSessionResponse: + return CloseSessionResponse() diff --git a/src/acp/agent/notifications.py b/src/acp/agent/notifications.py index d68d3c95f..7a97e9acf 100644 --- a/src/acp/agent/notifications.py +++ b/src/acp/agent/notifications.py @@ -3,39 +3,28 @@ from __future__ import annotations from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, assert_never +from typing import TYPE_CHECKING, Any -from pydantic_ai import ModelRequest, ModelResponse, ToolReturnPart, UserPromptPart import structlog from acp.schema import ( AgentMessageChunk, AgentPlanUpdate, AgentThoughtChunk, - AudioContentBlock, AvailableCommand, AvailableCommandsUpdate, - BlobResourceContents, ConfigOptionUpdate, ContentToolCallContent, CurrentModeUpdate, - EmbeddedResourceContentBlock, FileEditToolCallContent, - ImageContentBlock, - ResourceContentBlock, SessionNotification, - # CurrentModelUpdate, TerminalToolCallContent, - TextContentBlock, - TextResourceContents, ToolCallProgress, ToolCallStart, UserMessageChunk, ) from acp.schema.tool_call import ToolCallLocation from acp.tool_call_reporter import ToolCallReporter -from acp.utils import generate_tool_title, infer_tool_kind, to_acp_content_blocks -from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict if TYPE_CHECKING: @@ -74,7 +63,6 @@ def __init__(self, client: Client, session_id: str) -> None: self.client = client self.id = session_id self.log = logger.bind(session_id=session_id) - self._tool_call_inputs: dict[str, dict[str, Any]] = {} async def create_tool_reporter( self, @@ -167,7 +155,7 @@ async def tool_call_start( async def send_update(self, update: SessionUpdate) -> None: notification = SessionNotification(session_id=self.id, update=update) - await self.client.session_update(notification) # pyright: ignore[reportArgumentType] + await self.client.session_update(notification) async def tool_call_progress( self, @@ -439,134 +427,6 @@ async def send_user_resource( ) await self.send_update(update) - async def replay(self, messages: Sequence[ModelRequest | ModelResponse]) -> None: - """Replay a sequence of model messages as notifications.""" - for message in messages: - try: - match message: - case ModelRequest(): - await self._replay_request(message) - case ModelResponse(): - await self._replay_response(message) - case _ as unreachable: - assert_never(unreachable) - except Exception as e: - self.log.exception("Failed to replay message", error=str(e)) - - async def _replay_request(self, request: ModelRequest) -> None: - """Replay a ModelRequest by converting it to appropriate ACP notifications.""" - for part in request.parts: - match part: - case UserPromptPart(content=content) if isinstance(content, str): - # Handle both str and Sequence[UserContent] types - await self.send_user_message(content) - case UserPromptPart(content=content): - # Convert multi-modal content to appropriate ACP content blocks - converted_content = to_acp_content_blocks(content) - # Send each content block as separate notifications - for block in converted_content: - match block: - case TextContentBlock(text=text): - await self.send_user_message(text) - case ImageContentBlock(annotations=annots) as img_block: - await self.send_user_image( - data=img_block.data, - mime_type=img_block.mime_type, - uri=img_block.uri, - audience=annots.audience if annots else None, - last_modified=annots.last_modified if annots else None, - priority=annots.priority if annots else None, - ) - case AudioContentBlock(annotations=annots) as audio_block: - await self.send_user_audio( - data=audio_block.data, - mime_type=audio_block.mime_type, - audience=annots.audience if annots else None, - last_modified=annots.last_modified if annots else None, - priority=annots.priority if annots else None, - ) - case ResourceContentBlock(annotations=annots) as resource_block: - await self.send_user_resource( - uri=resource_block.uri, - name=resource_block.name, - description=resource_block.description, - mime_type=resource_block.mime_type, - size=resource_block.size, - title=resource_block.title, - audience=annots.audience if annots else None, - last_modified=annots.last_modified if annots else None, - priority=annots.priority if annots else None, - ) - case EmbeddedResourceContentBlock(resource=resource): - # Handle embedded resources with proper pattern matching - match resource: - case TextResourceContents(text=text): - await self.send_user_message(text) - case BlobResourceContents(blob=blob, mime_type=mime_type): - blob_size = len(blob) * 3 // 4 - size_mb = blob_size / (1024 * 1024) - mime = mime_type or "unknown" - msg = f"Embedded resource: {mime} ({size_mb:.2f} MB)" - await self.send_user_message(msg) - case _ as unreachable: - assert_never(unreachable) # ty: ignore[type-assertion-failure] - case _ as unreachable: - assert_never(unreachable) - - case ToolReturnPart( - content=content, tool_name=tool_name, tool_call_id=tool_call_id - ): - converted = to_acp_content_blocks(content) - tool_input = self._tool_call_inputs.get(tool_call_id, {}) - acp_content = [ContentToolCallContent(content=block) for block in converted] - locations = [ - ToolCallLocation(path=value) - for key, value in tool_input.items() - if key in {"path", "file_path", "filepath"} and isinstance(value, str) - ] - title = generate_tool_title(tool_name, tool_input) - await self.tool_call_progress( - tool_call_id=tool_call_id, - title=title, - status="completed", - locations=locations or None, - content=acp_content or None, - raw_output=converted, - ) - self._tool_call_inputs.pop(tool_call_id, None) - case _: - typ = type(part).__name__ - self.log.debug("Unhandled request part type", part_type=typ) - - async def _replay_response(self, response: ModelResponse) -> None: - """Replay a ModelResponse by converting it to appropriate ACP notifications.""" - from pydantic_ai import TextPart, ThinkingPart, ToolCallPart - - for part in response.parts: - match part: - case TextPart(content=content): - await self.send_agent_text(content) - - case ThinkingPart(content=content): - await self.send_agent_thought(content) - - case ToolCallPart(tool_call_id=tool_call_id, tool_name=tool_name): - # Store tool call inputs for later use with ToolReturnPart - tool_input = safe_args_as_dict(part) - self._tool_call_inputs[tool_call_id] = tool_input - # Send tool_call_start so UI can track the tool call - title = generate_tool_title(tool_name, tool_input) - await self.tool_call_start( - tool_call_id=tool_call_id, - title=title, - kind=infer_tool_kind(tool_name), - raw_input=tool_input, - ) - - case _: - typ = type(part).__name__ - self.log.debug("Unhandled response part type", part_type=typ) - async def send_agent_image( self, data: str | bytes, @@ -595,16 +455,14 @@ async def update_session_mode(self, mode_id: str) -> None: async def update_config_option( self, - config_id: str, - value_id: str, config_options: Sequence[SessionConfigOption], ) -> None: - """Send a config option update notification for a full config options update.""" - update = ConfigOptionUpdate( - config_id=config_id, - value_id=value_id, - config_options=config_options, - ) + """Send a config option update notification. + + Args: + config_options: The full set of configuration options with current values. + """ + update = ConfigOptionUpdate(config_options=config_options) await self.send_update(update) async def send_agent_audio( diff --git a/src/acp/agent/protocol.py b/src/acp/agent/protocol.py index b88337b9c..5ebbc5119 100644 --- a/src/acp/agent/protocol.py +++ b/src/acp/agent/protocol.py @@ -10,6 +10,8 @@ AuthenticateRequest, AuthenticateResponse, CancelNotification, + CloseSessionRequest, + CloseSessionResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, @@ -18,6 +20,8 @@ ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + LogoutRequest, + LogoutResponse, NewSessionRequest, NewSessionResponse, PromptRequest, @@ -30,8 +34,6 @@ SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, - StopSessionRequest, - StopSessionResponse, ) @@ -54,10 +56,12 @@ async def fork_session(self, params: ForkSessionRequest) -> ForkSessionResponse: async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionResponse: ... - async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: ... + async def close_session(self, params: CloseSessionRequest) -> CloseSessionResponse: ... async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: ... + async def logout(self, params: LogoutRequest) -> LogoutResponse | None: ... + async def set_session_mode( self, params: SetSessionModeRequest ) -> SetSessionModeResponse | None: ... diff --git a/src/acp/bridge/bridge.py b/src/acp/bridge/bridge.py index f7b7fb4da..c09e8e50b 100644 --- a/src/acp/bridge/bridge.py +++ b/src/acp/bridge/bridge.py @@ -24,6 +24,7 @@ from acp.schema import ( AuthenticateRequest, CancelNotification, + CloseSessionRequest, ForkSessionRequest, InitializeRequest, ListSessionsRequest, @@ -33,7 +34,6 @@ ResumeSessionRequest, SetSessionModelRequest, SetSessionModeRequest, - StopSessionRequest, ) from acp.transports import spawn_stdio_transport @@ -140,10 +140,10 @@ async def _dispatch_to_agent( # noqa: PLR0911 resume_session_request = ResumeSessionRequest.model_validate(params) resume_session_resp = await self._connection.resume_session(resume_session_request) return resume_session_resp.model_dump(by_alias=True, exclude_none=True) - case "session/stop": - stop_session_request = StopSessionRequest.model_validate(params) - stop_session_resp = await self._connection.stop_session(stop_session_request) - return stop_session_resp.model_dump(by_alias=True, exclude_none=True) + case "session/close": + close_session_request = CloseSessionRequest.model_validate(params) + close_session_resp = await self._connection.close_session(close_session_request) + return close_session_resp.model_dump(by_alias=True, exclude_none=True) case "session/prompt": prompt_request = PromptRequest.model_validate(params) prompt_resp = await self._connection.prompt(prompt_request) @@ -195,7 +195,7 @@ def _create_app(self) -> Starlette: middleware: list[Middleware] = [] if self.settings.allow_origins: mw = Middleware( - CORSMiddleware, # ty: ignore[invalid-argument-type] + CORSMiddleware, allow_origins=self.settings.allow_origins, allow_methods=["*"], allow_headers=["*"], diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py index fd959a793..ea1a59afc 100644 --- a/src/acp/client/connection.py +++ b/src/acp/client/connection.py @@ -12,12 +12,14 @@ from acp.exceptions import RequestError from acp.schema import ( AuthenticateResponse, + CloseSessionResponse, CreateTerminalRequest, ForkSessionResponse, InitializeResponse, KillTerminalCommandRequest, ListSessionsResponse, LoadSessionResponse, + LogoutResponse, NewSessionResponse, PromptResponse, ReadTextFileRequest, @@ -28,11 +30,14 @@ SetSessionConfigOptionResponse, SetSessionModelResponse, SetSessionModeResponse, - StopSessionResponse, TerminalOutputRequest, WaitForTerminalExitRequest, WriteTextFileRequest, ) +from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, +) if TYPE_CHECKING: @@ -46,25 +51,19 @@ AuthenticateRequest, CancelNotification, ClientMethod, - CreateTerminalResponse, + ClientResponse, + CloseSessionRequest, ForkSessionRequest, InitializeRequest, - KillTerminalCommandResponse, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, - ReadTextFileResponse, - ReleaseTerminalResponse, - RequestPermissionResponse, ResumeSessionRequest, SetSessionConfigOptionRequest, SetSessionModelRequest, SetSessionModeRequest, - StopSessionRequest, - TerminalOutputResponse, - WaitForTerminalExitResponse, - WriteTextFileResponse, ) @@ -135,13 +134,13 @@ async def resume_session(self, params: ResumeSessionRequest) -> ResumeSessionRes payload = resp if isinstance(resp, dict) else {} return ResumeSessionResponse.model_validate(payload) - async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: + async def close_session(self, params: CloseSessionRequest) -> CloseSessionResponse: dct = params.model_dump( mode="json", by_alias=True, exclude_none=True, exclude_defaults=True ) - resp = await self._conn.send_request("session/stop", dct) + resp = await self._conn.send_request("session/close", dct) payload = resp if isinstance(resp, dict) else {} - return StopSessionResponse.model_validate(payload) + return CloseSessionResponse.model_validate(payload) async def set_session_mode(self, params: SetSessionModeRequest) -> SetSessionModeResponse: dct = params.model_dump( @@ -177,6 +176,14 @@ async def authenticate(self, params: AuthenticateRequest) -> AuthenticateRespons payload = resp if isinstance(resp, dict) else {} return AuthenticateResponse.model_validate(payload) + async def logout(self, params: LogoutRequest) -> LogoutResponse: + dct = params.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude_defaults=True + ) + resp = await self._conn.send_request("logout", dct) + payload = resp if isinstance(resp, dict) else {} + return LogoutResponse.model_validate(payload) + async def prompt(self, params: PromptRequest) -> PromptResponse: # Don't exclude_defaults here - the 'type' field in content blocks has a default # value but is required for discriminated unions to work @@ -212,19 +219,7 @@ async def _handle_client_method( # noqa: PLR0911 method: ClientMethod | str, params: dict[str, Any] | None, is_notification: bool, -) -> ( - WriteTextFileResponse - | ReadTextFileResponse - | RequestPermissionResponse - | SessionNotification - | CreateTerminalResponse - | TerminalOutputResponse - | WaitForTerminalExitResponse - | ReleaseTerminalResponse - | KillTerminalCommandResponse - | dict[str, Any] - | None -): +) -> ClientResponse | dict[str, Any] | None: """Handle client method calls.""" match method: case "fs/write_text_file": @@ -255,6 +250,13 @@ async def _handle_client_method( # noqa: PLR0911 case "terminal/kill": kill_request = KillTerminalCommandRequest.model_validate(params) return await client.kill_terminal(kill_request) + case "session/elicitation": + elicitation_request = ElicitationRequest.model_validate(params) + return await client.elicitation(elicitation_request) + case "session/elicitation/complete": + complete_notification = ElicitationCompleteNotification.model_validate(params) + await client.elicitation_complete(complete_notification) + return None case str() if method.startswith("_") and is_notification: await client.ext_notification(method[1:], params or {}) return None diff --git a/src/acp/client/implementations/default_client.py b/src/acp/client/implementations/default_client.py index ed6a4ceaa..f64612368 100644 --- a/src/acp/client/implementations/default_client.py +++ b/src/acp/client/implementations/default_client.py @@ -34,6 +34,11 @@ WaitForTerminalExitResponse, WriteTextFileRequest, ) + from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, + ElicitationResponse, + ) logger = structlog.get_logger(__name__) @@ -182,5 +187,14 @@ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any self.ext_calls.append((method, params)) return {"ok": True, "method": method} + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: + """Decline elicitation by default.""" + from acp.schema.elicitation import ElicitationDeclineAction, ElicitationResponse + + return ElicitationResponse(action=ElicitationDeclineAction()) + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: + """Ignore elicitation complete notifications.""" + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: self.ext_notes.append((method, params)) diff --git a/src/acp/client/implementations/headless_client.py b/src/acp/client/implementations/headless_client.py index 6863f5610..abcd92664 100644 --- a/src/acp/client/implementations/headless_client.py +++ b/src/acp/client/implementations/headless_client.py @@ -39,6 +39,11 @@ WaitForTerminalExitRequest, WriteTextFileRequest, ) + from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, + ElicitationResponse, + ) logger = structlog.get_logger(__name__) @@ -257,6 +262,15 @@ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any logger.debug("Extension method called", method=method) return {"ok": True, "method": method, "params": params} + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: + """Decline elicitation by default.""" + from acp.schema.elicitation import ElicitationDeclineAction, ElicitationResponse + + return ElicitationResponse(action=ElicitationDeclineAction()) + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: + """Ignore elicitation complete notifications.""" + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: """Handle extension notifications.""" logger.debug("Extension notification", method=method) diff --git a/src/acp/client/implementations/noop_client.py b/src/acp/client/implementations/noop_client.py index ba9195b04..4b3440b21 100644 --- a/src/acp/client/implementations/noop_client.py +++ b/src/acp/client/implementations/noop_client.py @@ -33,6 +33,11 @@ WriteTextFileRequest, WriteTextFileResponse, ) + from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, + ElicitationResponse, + ) class NoOpClient(Client): @@ -106,5 +111,14 @@ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any """Return empty dict for extension methods.""" return {} + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: + """Decline elicitation by default.""" + from acp.schema.elicitation import ElicitationDeclineAction, ElicitationResponse + + return ElicitationResponse(action=ElicitationDeclineAction()) + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: + """Ignore elicitation complete notifications.""" + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: """Ignore extension notifications.""" diff --git a/src/acp/client/protocol.py b/src/acp/client/protocol.py index f366cbebb..c983a0438 100644 --- a/src/acp/client/protocol.py +++ b/src/acp/client/protocol.py @@ -23,6 +23,11 @@ WriteTextFileRequest, WriteTextFileResponse, ) + from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, + ElicitationResponse, + ) class Client(Protocol): @@ -56,6 +61,10 @@ async def kill_terminal( self, params: KillTerminalCommandRequest ) -> KillTerminalCommandResponse | None: ... + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: ... + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: ... + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ... async def ext_notification(self, method: str, params: dict[str, Any]) -> None: ... diff --git a/src/acp/connection.py b/src/acp/connection.py index 1327de6f5..478c0a6e9 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -170,9 +170,12 @@ async def _receive_loop(self) -> None: except asyncio.CancelledError: return except anyio.ClosedResourceError: - return + pass except anyio.EndOfStream: - return + pass + # EOF / closed: reject in-flight requests so callers get an error + # instead of hanging forever (e.g. subprocess crash during initialize). + self._state.reject_all_outgoing(ConnectionError("Connection closed: remote end sent EOF")) async def _process_message(self, message: dict[str, Any]) -> None: method = message.get("method") diff --git a/src/acp/exceptions.py b/src/acp/exceptions.py index 79b6a9e58..d745b5255 100644 --- a/src/acp/exceptions.py +++ b/src/acp/exceptions.py @@ -57,6 +57,11 @@ def auth_required( ) -> Self: return cls(-32000, "Authentication required", data, auth_methods=auth_methods) + @classmethod + def url_elicitation_required(cls, data: dict[str, Any] | None = None) -> Self: + """**UNSTABLE**: The agent requires user input via a URL-based elicitation.""" + return cls(-32042, "URL elicitation required", data) + def to_error_obj(self) -> dict[str, Any]: result: dict[str, Any] = {"code": self.code, "message": str(self), "data": self.data} if self.auth_methods: diff --git a/src/acp/filesystem.py b/src/acp/filesystem.py index 0da264dfa..f95f7a542 100644 --- a/src/acp/filesystem.py +++ b/src/acp/filesystem.py @@ -46,7 +46,7 @@ def _fetch_range(self, start: int | None, end: int | None) -> bytes: """Fetch byte range from file (sync wrapper).""" if self._content is None: # Run the async operation in the event loop - self._content = self.fs.cat_file(self.path) # pyright: ignore[reportAttributeAccessIssue] + self._content = self.fs.cat_file(self.path) assert self._content if start is None and end is None: @@ -148,7 +148,7 @@ async def _cat_file( except Exception as e: raise FileNotFoundError(f"Could not read file {path}: {e}") from e - cat_file = sync_wrapper(_cat_file) # pyright: ignore[reportAssignmentType] + cat_file = sync_wrapper(_cat_file) async def _put_file( self, lpath: str, rpath: str, mode: str = "overwrite", **kwargs: Any @@ -315,7 +315,7 @@ async def _exists(self, path: str, **kwargs: Any) -> bool: else: return exists_cmd.parse_command(output, exit_code if exit_code is not None else 1) - exists = sync_wrapper(_exists) # pyright: ignore[reportAssignmentType] + exists = sync_wrapper(_exists) async def _isdir(self, path: str, **kwargs: Any) -> bool: """Check if path is a directory via test command. @@ -525,7 +525,7 @@ async def _find( logger.warning("CLI find error, falling back to walk: %s", e) return await super()._find(path, maxdepth=maxdepth, withdirs=withdirs, **kwargs) # type: ignore[no-any-return] - find = sync_wrapper(_find) # pyright: ignore[reportAssignmentType] + find = sync_wrapper(_find) def open( self, diff --git a/src/acp/registry/__init__.py b/src/acp/registry/__init__.py index 5530d5de7..2383a5863 100644 --- a/src/acp/registry/__init__.py +++ b/src/acp/registry/__init__.py @@ -13,7 +13,6 @@ RegistryAgent, UvxDistribution, ) -from acp.registry.prepare import prepare_agent __all__ = [ "BaseDistribution", @@ -27,5 +26,4 @@ "fetch_agent", "fetch_registry", "list_agents", - "prepare_agent", ] diff --git a/src/acp/registry/fetch.py b/src/acp/registry/fetch.py index 955d139de..31be626ea 100644 --- a/src/acp/registry/fetch.py +++ b/src/acp/registry/fetch.py @@ -4,7 +4,7 @@ from typing import Final -import httpx +import anyenv from acp.registry.model import DistributionUnion, Registry, RegistryAgent, UvxDistribution @@ -39,11 +39,12 @@ def _merge_builtin_agents(agents: list[RegistryAgent]) -> list[RegistryAgent]: async def fetch_registry() -> Registry: """Fetch the ACP registry data.""" - async with httpx.AsyncClient() as client: - response = await client.get(REGISTRY_URL, headers={"User-Agent": "Mozilla/5.0"}) - response.raise_for_status() - data = response.json() - return Registry.model_validate(data) + return await anyenv.get_json( + REGISTRY_URL, + headers={"User-Agent": "Mozilla/5.0"}, + return_type=Registry, + cache=True, + ) async def list_agents() -> list[RegistryAgent]: diff --git a/src/acp/registry/model.py b/src/acp/registry/model.py index 7e7e0c269..41829716e 100644 --- a/src/acp/registry/model.py +++ b/src/acp/registry/model.py @@ -10,6 +10,8 @@ from pydantic import BaseModel, Field +from acp.registry.prepare import DEFAULT_BIN_DIR, prepare_binary, prepare_npx, prepare_uvx + def get_platform_key() -> str: """Return a ``{system}-{arch}`` key for the current platform.""" @@ -121,6 +123,24 @@ def dist(self) -> Distribution: case _: raise ValueError("Unsupported distribution type.") + async def prepare( + self, + extra_args: list[str] | None = None, + *, + bin_dir: Path = DEFAULT_BIN_DIR, + ) -> list[str]: + """Resolve this agent to a runnable command list.""" + args = extra_args or [] + match self.dist: + case NpxDistribution() as dist: + return prepare_npx(dist, args) + case UvxDistribution() as dist: + return prepare_uvx(dist, args) + case BinaryDistribution() as dist: + return await prepare_binary(dist, args, bin_dir) + case _: + raise ValueError("Unsupported distribution type.") + class Registry(BaseModel): """Top-level ACP registry response.""" diff --git a/src/acp/registry/prepare.py b/src/acp/registry/prepare.py index 3d33e046e..a60c843a6 100644 --- a/src/acp/registry/prepare.py +++ b/src/acp/registry/prepare.py @@ -16,11 +16,10 @@ import httpx from acp.registry.archive import extract_binary -from acp.registry.model import BinaryDistribution, NpxDistribution, UvxDistribution if TYPE_CHECKING: - from acp.registry.model import RegistryAgent + from acp.registry.model import BinaryDistribution, NpxDistribution, UvxDistribution logger = logging.getLogger(__name__) @@ -36,7 +35,7 @@ def _find_program(*candidates: str) -> str | None: return None -def _prepare_npx(dist: NpxDistribution, extra_args: list[str]) -> list[str]: +def prepare_npx(dist: NpxDistribution, extra_args: list[str]) -> list[str]: """Build command list for an npx/bunx distribution.""" runner = _find_program("bunx", "npx") if runner is None: @@ -47,14 +46,14 @@ def _prepare_npx(dist: NpxDistribution, extra_args: list[str]) -> list[str]: return [*base, dist.package, *dist.args, *extra_args] -def _prepare_uvx(dist: UvxDistribution, extra_args: list[str]) -> list[str]: +def prepare_uvx(dist: UvxDistribution, extra_args: list[str]) -> list[str]: """Build command list for a uvx distribution.""" if not shutil.which("uvx"): raise RuntimeError("uvx not found on PATH. Install uv.") return ["uvx", "--python", "3.13", dist.package, *dist.args, *extra_args] -async def _prepare_binary( +async def prepare_binary( dist: BinaryDistribution, extra_args: list[str], bin_dir: Path, @@ -89,27 +88,3 @@ async def _prepare_binary( bin_path.chmod(0o755) logger.info("Binary installed to %s", bin_path) return cmd - - -async def prepare_agent( - agent: RegistryAgent, - extra_args: list[str] | None = None, - *, - bin_dir: Path = DEFAULT_BIN_DIR, -) -> list[str]: - """Resolve a registry agent to a runnable command list. - - For uvx/npx this just builds the command. For binary distributions - this downloads and extracts the binary if not already present. - - Returns: - A command list suitable for ``subprocess.Popen`` / ``anyio.open_process``. - """ - args = extra_args or [] - match agent.dist: - case NpxDistribution() as dist: - return _prepare_npx(dist, args) - case UvxDistribution() as dist: - return _prepare_uvx(dist, args) - case BinaryDistribution() as dist: - return await _prepare_binary(dist, args, bin_dir) diff --git a/src/acp/schema/__init__.py b/src/acp/schema/__init__.py index e420ebb9c..b23b4f2b7 100644 --- a/src/acp/schema/__init__.py +++ b/src/acp/schema/__init__.py @@ -19,6 +19,7 @@ ForkSessionResponse, InitializeResponse, LoadSessionResponse, + LogoutResponse, NewSessionResponse, ListSessionsResponse, PromptResponse, @@ -27,20 +28,22 @@ SetSessionModeResponse, SetSessionModelResponse, StopReason, - StopSessionResponse, + CloseSessionResponse, ) from acp.schema.capabilities import ( + AgentAuthCapabilities, AgentCapabilities, AuthCapabilities, ClientCapabilities, - FileSystemCapability, + FileSystemCapabilities, + LogoutCapabilities, McpCapabilities, PromptCapabilities, SessionCapabilities, SessionForkCapabilities, SessionListCapabilities, SessionResumeCapabilities, - SessionStopCapabilities, + SessionCloseCapabilities, ) from acp.schema.client_requests import ( AuthenticateRequest, @@ -50,13 +53,14 @@ InitializeRequest, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ResumeSessionRequest, SetSessionConfigOptionRequest, SetSessionModeRequest, SetSessionModelRequest, - StopSessionRequest, + CloseSessionRequest, ) from acp.schema.client_responses import ( ClientResponse, @@ -69,6 +73,34 @@ WaitForTerminalExitResponse, WriteTextFileResponse, ) +from acp.schema.elicitation import ( + BooleanPropertySchema, + ElicitationAcceptAction, + ElicitationAction, + ElicitationCancelAction, + ElicitationCapabilities, + ElicitationCompleteNotification, + ElicitationContentValue, + ElicitationDeclineAction, + ElicitationFormCapabilities, + ElicitationFormMode, + ElicitationMode, + ElicitationPropertySchema, + ElicitationRequest, + ElicitationResponse, + ElicitationSchema, + ElicitationUrlCapabilities, + ElicitationUrlMode, + EnumOption, + IntegerPropertySchema, + MultiSelectItems, + MultiSelectPropertySchema, + NumberPropertySchema, + StringFormatLiteral, + StringPropertySchema, + TitledMultiSelectItems, + UntitledMultiSelectItems, +) from acp.schema.common import ( AuthEnvVar, AuthMethod, @@ -105,18 +137,26 @@ ExtNotification, SessionNotification, ) + +# ElicitationCompleteNotification is re-exported from elicitation above from acp.schema.session_state import ( ModelInfo, + SessionInfo, + SessionMode, + SessionModeState, + SessionModelState, +) +from acp.schema.config_options import ( + BooleanSessionConfigOption, + SelectSessionConfigOption, SessionConfigOption, SessionConfigOptionCategory, - SessionConfigSelect, + SessionConfigOptionValue, + SessionConfigOptionValueBoolean, + SessionConfigOptionValueId, SessionConfigSelectGroup, SessionConfigSelectOption, SessionConfigSelectOptions, - SessionInfo, - SessionMode, - SessionModeState, - SessionModelState, ) from acp.schema.slash_commands import ( AvailableCommand, @@ -146,7 +186,6 @@ ConfigOptionUpdate, Cost, CurrentModeUpdate, - CurrentModelUpdate, SessionInfoUpdate, SessionUpdate, ToolCallProgress, @@ -160,6 +199,7 @@ __all__ = [ "PROTOCOL_VERSION", + "AgentAuthCapabilities", "AgentCapabilities", "AgentMessageChunk", "AgentMethod", @@ -184,12 +224,16 @@ "AvailableCommandInput", "AvailableCommandsUpdate", "BlobResourceContents", + "BooleanPropertySchema", + "BooleanSessionConfigOption", "CancelNotification", "ClientCapabilities", "ClientMethod", "ClientNotification", "ClientRequest", "ClientResponse", + "CloseSessionRequest", + "CloseSessionResponse", "CommandInputHint", "ConfigOptionUpdate", "ContentBlock", @@ -198,15 +242,31 @@ "CreateTerminalRequest", "CreateTerminalResponse", "CurrentModeUpdate", - "CurrentModelUpdate", "CustomRequest", "CustomResponse", "DeniedOutcome", + "ElicitationAcceptAction", + "ElicitationAction", + "ElicitationCancelAction", + "ElicitationCapabilities", + "ElicitationCompleteNotification", + "ElicitationContentValue", + "ElicitationDeclineAction", + "ElicitationFormCapabilities", + "ElicitationFormMode", + "ElicitationMode", + "ElicitationPropertySchema", + "ElicitationRequest", + "ElicitationResponse", + "ElicitationSchema", + "ElicitationUrlCapabilities", + "ElicitationUrlMode", "EmbeddedResourceContentBlock", + "EnumOption", "EnvVariable", "ExtNotification", "FileEditToolCallContent", - "FileSystemCapability", + "FileSystemCapabilities", "ForkSessionRequest", "ForkSessionResponse", "HttpHeader", @@ -215,17 +275,24 @@ "Implementation", "InitializeRequest", "InitializeResponse", + "IntegerPropertySchema", "KillTerminalCommandRequest", "KillTerminalCommandResponse", "ListSessionsRequest", "ListSessionsResponse", "LoadSessionRequest", "LoadSessionResponse", + "LogoutCapabilities", + "LogoutRequest", + "LogoutResponse", "McpCapabilities", "McpServer", "ModelInfo", + "MultiSelectItems", + "MultiSelectPropertySchema", "NewSessionRequest", "NewSessionResponse", + "NumberPropertySchema", "PermissionKind", "PermissionOption", "PlanEntry", @@ -243,10 +310,14 @@ "ResourceContentBlock", "ResumeSessionRequest", "ResumeSessionResponse", + "SelectSessionConfigOption", "SessionCapabilities", + "SessionCloseCapabilities", "SessionConfigOption", "SessionConfigOptionCategory", - "SessionConfigSelect", + "SessionConfigOptionValue", + "SessionConfigOptionValueBoolean", + "SessionConfigOptionValueId", "SessionConfigSelectGroup", "SessionConfigSelectOption", "SessionConfigSelectOptions", @@ -259,7 +330,6 @@ "SessionModelState", "SessionNotification", "SessionResumeCapabilities", - "SessionStopCapabilities", "SessionUpdate", "SetSessionConfigOptionRequest", "SetSessionConfigOptionResponse", @@ -270,14 +340,15 @@ "SseMcpServer", "StdioMcpServer", "StopReason", - "StopSessionRequest", - "StopSessionResponse", + "StringFormatLiteral", + "StringPropertySchema", "TerminalExitStatus", "TerminalOutputRequest", "TerminalOutputResponse", "TerminalToolCallContent", "TextContentBlock", "TextResourceContents", + "TitledMultiSelectItems", "ToolCall", "ToolCallContent", "ToolCallKind", @@ -285,6 +356,7 @@ "ToolCallProgress", "ToolCallStart", "ToolCallStatus", + "UntitledMultiSelectItems", "Usage", "UsageUpdate", "UserMessageChunk", diff --git a/src/acp/schema/agent_requests.py b/src/acp/schema/agent_requests.py index 312cf2f60..d64179e41 100644 --- a/src/acp/schema/agent_requests.py +++ b/src/acp/schema/agent_requests.py @@ -6,6 +6,7 @@ from acp.schema.base import Request from acp.schema.common import EnvVariable # noqa: TC001 +from acp.schema.elicitation import ElicitationRequest from acp.schema.tool_call import PermissionOption, ToolCall # noqa: TC001 @@ -123,4 +124,5 @@ class RequestPermissionRequest(BaseAgentRequest): | ReleaseTerminalRequest | WaitForTerminalExitRequest | KillTerminalCommandRequest + | ElicitationRequest ) diff --git a/src/acp/schema/agent_responses.py b/src/acp/schema/agent_responses.py index 4c702fd96..6c7d1b46c 100644 --- a/src/acp/schema/agent_responses.py +++ b/src/acp/schema/agent_responses.py @@ -8,12 +8,8 @@ from acp.schema.base import Response from acp.schema.capabilities import AgentCapabilities from acp.schema.common import AuthMethod, Implementation # noqa: TC001 -from acp.schema.session_state import ( # noqa: TC001 - SessionConfigOption, - SessionInfo, - SessionModelState, - SessionModeState, -) +from acp.schema.config_options import SessionConfigOption # noqa: TC001 +from acp.schema.session_state import SessionInfo, SessionModelState, SessionModeState # noqa: TC001 from acp.schema.session_updates import Usage # noqa: TC001 @@ -65,12 +61,7 @@ class NewSessionResponse(Response): """ config_options: Sequence[SessionConfigOption] | None = None - """**UNSTABLE** - - Configuration options for this session. - - See RFD: Session Config Options - """ + """**Configuration options for this session.""" session_id: str """Unique identifier for the created session. @@ -242,7 +233,7 @@ class PromptResponse(Response): """ -class StopSessionResponse(Response): +class CloseSessionResponse(Response): """**UNSTABLE**: This capability is not part of the spec yet. Response from stopping a session. @@ -253,6 +244,10 @@ class AuthenticateResponse(Response): """Response to authenticate method.""" +class LogoutResponse(Response): + """**UNSTABLE**: Response to the ``logout`` method.""" + + class InitializeResponse(Response): """Response from the initialize method. @@ -295,7 +290,7 @@ def create( image_prompts: bool = False, list_sessions: bool = False, resume_session: bool = False, - stop_session: bool = False, + close_session: bool = False, auth_methods: Sequence[AuthMethod] | None = None, ) -> Self: """Create an instance of AgentCapabilities. @@ -313,7 +308,7 @@ def create( image_prompts: Whether the agent supports image prompts. list_sessions: Whether the agent supports `session/list` (unstable). resume_session: Whether the agent supports `session/resume` (unstable). - stop_session: Whether the agent supports `session/stop` (unstable). + close_session: Whether the agent supports `session/close` (unstable). auth_methods: The authentication methods supported by the agent. """ caps = AgentCapabilities.create( @@ -325,7 +320,7 @@ def create( image_prompts=image_prompts, list_sessions=list_sessions, resume_session=resume_session, - stop_session=stop_session, + close_session=close_session, ) return cls( agent_info=Implementation(name=name, title=title, version=version), @@ -352,11 +347,12 @@ class ListSessionsResponse(Response): AgentResponse = ( InitializeResponse | AuthenticateResponse + | LogoutResponse | NewSessionResponse | LoadSessionResponse | ForkSessionResponse | ResumeSessionResponse - | StopSessionResponse + | CloseSessionResponse | ListSessionsResponse | SetSessionModeResponse | SetSessionConfigOptionResponse diff --git a/src/acp/schema/capabilities.py b/src/acp/schema/capabilities.py index 328a585d1..b40472a7c 100644 --- a/src/acp/schema/capabilities.py +++ b/src/acp/schema/capabilities.py @@ -7,9 +7,10 @@ from pydantic import Field from acp.schema.base import AnnotatedObject +from acp.schema.elicitation import ElicitationCapabilities # noqa: TC001 -class FileSystemCapability(AnnotatedObject): +class FileSystemCapabilities(AnnotatedObject): """File system capabilities that a client may support. See protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem) @@ -45,7 +46,13 @@ class ClientCapabilities(AnnotatedObject): auth: AuthCapabilities | None = None """**UNSTABLE**: Authentication capabilities supported by the client.""" - fs: FileSystemCapability | None = Field(default_factory=FileSystemCapability) + elicitation: ElicitationCapabilities | None = None + """**UNSTABLE**: Elicitation capabilities supported by the client. + + Determines which elicitation modes the agent may use. + """ + + fs: FileSystemCapabilities | None = Field(default_factory=FileSystemCapabilities) """File system capabilities supported by the client. Determines which file operations the agent can request. @@ -73,7 +80,7 @@ def create( Returns: A new instance of ClientCapabilities. """ - fs = FileSystemCapability(read_text_file=read_text_file, write_text_file=write_text_file) + fs = FileSystemCapabilities(read_text_file=read_text_file, write_text_file=write_text_file) return cls(fs=fs, terminal=terminal, auth=auth) @@ -148,8 +155,8 @@ class SessionResumeCapabilities(AnnotatedObject): """ -class SessionStopCapabilities(AnnotatedObject): - """Capabilities for the `session/stop` method. +class SessionCloseCapabilities(AnnotatedObject): + """Capabilities for the `session/close` method. **UNSTABLE**: This capability is not part of the spec yet, and may be removed or changed at any point. @@ -197,12 +204,29 @@ class SessionCapabilities(AnnotatedObject): Whether the agent supports `session/resume`. """ - stop: SessionStopCapabilities | None = None + close: SessionCloseCapabilities | None = None """**UNSTABLE** This capability is not part of the spec yet, and may be removed or changed at any point. - Whether the agent supports `session/stop`. + Whether the agent supports `session/close`. + """ + + +class LogoutCapabilities(AnnotatedObject): + """**UNSTABLE**: Logout capabilities supported by the agent. + + By supplying ``{}`` it means that the agent supports the logout method. + """ + + +class AgentAuthCapabilities(AnnotatedObject): + """**UNSTABLE**: Authentication-related capabilities supported by the agent.""" + + logout: LogoutCapabilities | None = None + """Whether the agent supports the logout method. + + By supplying ``{}`` it means that the agent supports the logout method. """ @@ -215,6 +239,9 @@ class AgentCapabilities(AnnotatedObject): See protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities) """ + auth: AgentAuthCapabilities | None = Field(default_factory=AgentAuthCapabilities) + """**UNSTABLE**: Authentication-related capabilities supported by the agent.""" + load_session: bool | None = False """Whether the agent supports `session/load`.""" @@ -238,7 +265,7 @@ def create( image_prompts: bool = False, list_sessions: bool = False, resume_session: bool = False, - stop_session: bool = False, + close_session: bool = False, ) -> Self: """Create an instance of AgentCapabilities. @@ -251,12 +278,12 @@ def create( image_prompts: Whether the agent supports image prompts. list_sessions: Whether the agent supports `session/list` (unstable). resume_session: Whether the agent supports `session/resume` (unstable). - stop_session: Whether the agent supports `session/stop` (unstable). + close_session: Whether the agent supports `session/close` (unstable). """ session_caps = SessionCapabilities( list=SessionListCapabilities() if list_sessions else None, resume=SessionResumeCapabilities() if resume_session else None, - stop=SessionStopCapabilities() if stop_session else None, + close=SessionCloseCapabilities() if close_session else None, ) return cls( load_session=load_session, diff --git a/src/acp/schema/client_requests.py b/src/acp/schema/client_requests.py index 102d8cdfd..59b65b06e 100644 --- a/src/acp/schema/client_requests.py +++ b/src/acp/schema/client_requests.py @@ -8,7 +8,7 @@ from pydantic import Field from acp.schema.base import Request -from acp.schema.capabilities import AuthCapabilities, ClientCapabilities, FileSystemCapability +from acp.schema.capabilities import AuthCapabilities, ClientCapabilities, FileSystemCapabilities from acp.schema.common import Implementation from acp.schema.content_blocks import ContentBlock # noqa: TC001 from acp.schema.mcp import McpServer # noqa: TC001 @@ -175,6 +175,10 @@ class SetSessionModelRequest(Request): class SetSessionConfigOptionRequest(Request): """Request parameters for setting a session configuration option. + Supports both select (string value ID) and boolean config options. + When ``type`` is ``"boolean"``, ``value`` is a bool. + When ``type`` is absent or ``"value_id"``, ``value`` is a string. + See protocol docs: [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) """ @@ -184,8 +188,11 @@ class SetSessionConfigOptionRequest(Request): session_id: str """The ID of the session to set the config option for.""" - value: str = Field(serialization_alias="valueId") - """The ID of the value to set for this configuration option.""" + type: str | None = None + """The value type discriminator. ``"boolean"`` for bool values, absent for string value IDs.""" + + value: str | bool + """The value to set. String for select options, bool for boolean options.""" class InitializeRequest(Request): @@ -222,7 +229,7 @@ def create( metadata: dict[str, Any] | None = None, ) -> Self: """Create a new InitializeRequest instance.""" - fs = FileSystemCapability(read_text_file=read_text_file, write_text_file=write_text_file) + fs = FileSystemCapabilities(read_text_file=read_text_file, write_text_file=write_text_file) auth = AuthCapabilities(terminal=terminal_auth) if terminal_auth else None caps = ClientCapabilities(terminal=terminal, fs=fs, auth=auth) impl = Implementation(title=title, name=name, version=version) @@ -259,7 +266,7 @@ def create_for_package( ) -class StopSessionRequest(Request): +class CloseSessionRequest(Request): """**UNSTABLE**: This capability is not part of the spec yet. Request parameters for stopping an active session. @@ -288,15 +295,26 @@ class AuthenticateRequest(Request): """ +class LogoutRequest(Request): + """**UNSTABLE**: Request parameters for the logout method. + + Terminates the current authenticated session. + + After a successful logout, all new sessions will require authentication. + There is no guarantee about the behavior of already running sessions. + """ + + ClientRequest = ( InitializeRequest | AuthenticateRequest + | LogoutRequest | NewSessionRequest | LoadSessionRequest | ListSessionsRequest | ForkSessionRequest | ResumeSessionRequest - | StopSessionRequest + | CloseSessionRequest | SetSessionModeRequest | SetSessionConfigOptionRequest | PromptRequest diff --git a/src/acp/schema/client_responses.py b/src/acp/schema/client_responses.py index 2edc37a33..e72fad433 100644 --- a/src/acp/schema/client_responses.py +++ b/src/acp/schema/client_responses.py @@ -5,6 +5,7 @@ from typing import Any, Self from acp.schema.base import Response +from acp.schema.elicitation import ElicitationResponse from acp.schema.terminal import TerminalExitStatus # noqa: TC001 from acp.schema.tool_call import AllowedOutcome, DeniedOutcome @@ -93,4 +94,5 @@ def allowed(cls, option_id: str, metadata: dict[str, Any] | None = None) -> Self | ReleaseTerminalResponse | WaitForTerminalExitResponse | KillTerminalCommandResponse + | ElicitationResponse ) diff --git a/src/acp/schema/config_options.py b/src/acp/schema/config_options.py new file mode 100644 index 000000000..df3432ab3 --- /dev/null +++ b/src/acp/schema/config_options.py @@ -0,0 +1,163 @@ +"""Session state schema definitions.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Annotated, Literal + +from pydantic import Discriminator, Field + +from acp.schema.base import AnnotatedObject + + +# Type aliases for config option identifiers +SessionConfigId = str +"""Unique identifier for a configuration option.""" + +SessionConfigValueId = str +"""Unique identifier for a possible value within a configuration option.""" + +SessionConfigGroupId = str +"""Unique identifier for a group of values within a configuration option.""" + + +SessionConfigOptionCategory = Literal["mode", "model", "thought_level", "other"] +"""Semantic category for a session configuration option. + +This is intended to help Clients distinguish broadly common selectors (e.g. model selector vs +session mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons, +placement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown +categories gracefully (treat as `other`). +""" + + +class SessionConfigSelectOption(AnnotatedObject): + """A possible value for a configuration selector.""" + + value: SessionConfigValueId + """Unique identifier for this option value.""" + + name: str + """Human-readable label for this option value.""" + + description: str | None = None + """Optional description for this option value.""" + + +class SessionConfigSelectGroup(AnnotatedObject): + """A group of possible values for a configuration selector.""" + + group: SessionConfigGroupId + """Unique identifier for this group.""" + + name: str + """Human-readable label for this group.""" + + options: Sequence[SessionConfigSelectOption] + """The set of option values in this group.""" + + +SessionConfigSelectOptions = ( + Sequence[SessionConfigSelectOption] | Sequence[SessionConfigSelectGroup] +) +"""The possible values for a configuration selector, optionally organized into groups.""" + + +class BaseSessionConfigOption(AnnotatedObject): + """Base fields shared by all session config option variants.""" + + id: SessionConfigId + """Unique identifier for the configuration option.""" + + name: str + """Human-readable label for the option.""" + + description: str | None = None + """Optional description for the Client to display to the user.""" + + category: SessionConfigOptionCategory | None = None + """Optional semantic category for this option (UX only).""" + + +class SelectSessionConfigOption(BaseSessionConfigOption): + """A select-type session configuration option. + + Single-value selector (dropdown) with a list of options. + + Advertised by the agent to describe an available select config option and its current state. + See ``SessionConfigOptionValueId`` for the value sent by the client when changing it. + """ + + type: Literal["select"] = Field(default="select", init=False) + """Discriminator for the config option type.""" + + current_value: SessionConfigValueId + """The currently selected value.""" + + options: SessionConfigSelectOptions + """The set of selectable options.""" + + +class BooleanSessionConfigOption(BaseSessionConfigOption): + """**UNSTABLE**: This capability is not part of the spec yet. + + A boolean on/off toggle session configuration option. + + Advertised by the agent to describe an available boolean config option and its current state. + See ``SessionConfigOptionValueBoolean`` for the value sent by the client when changing it. + """ + + type: Literal["boolean"] = Field(default="boolean", init=False) + """Discriminator for the config option type.""" + + current_value: bool + """The current value of the boolean option.""" + + +SessionConfigOption = Annotated[ + SelectSessionConfigOption | BooleanSessionConfigOption, + Discriminator("type"), +] +"""A session configuration option, discriminated by ``type``. + +For ``type: "select"`` the ``options`` and ``current_value`` (string) fields +are present. For ``type: "boolean"`` only ``current_value`` (bool) is present.""" + + +# --- SetSessionConfigOption value types --- + + +class SessionConfigOptionValueBoolean(AnnotatedObject): + """A boolean value for setting a config option (type: "boolean"). + + Sent by the client to change a boolean config option. + See ``BooleanSessionConfigOption`` for the option definition advertised by the agent. + """ + + type: Literal["boolean"] = Field(default="boolean", init=False) + """Discriminator value.""" + + value: bool + """The boolean value.""" + + +class SessionConfigOptionValueId(AnnotatedObject): + """A SessionConfigValueId string value for setting a config option. + + This is the default when ``type`` is absent on the wire. Unknown ``type`` + values with string payloads also gracefully deserialize into this variant. + + Sent by the client to change a select config option. + See ``SelectSessionConfigOption`` for the option definition advertised by the agent. + """ + + value: SessionConfigValueId + """The value ID.""" + + +SessionConfigOptionValue = SessionConfigOptionValueBoolean | SessionConfigOptionValueId +"""The value to set for a session configuration option. + +When ``type`` is ``"boolean"``, carries a bool. Otherwise (or when ``type`` +is absent), carries a ``SessionConfigValueId`` string. +""" diff --git a/src/acp/schema/content_blocks.py b/src/acp/schema/content_blocks.py index 623e04bc4..5872a71f7 100644 --- a/src/acp/schema/content_blocks.py +++ b/src/acp/schema/content_blocks.py @@ -78,9 +78,7 @@ class BaseContentBlock(AnnotatedObject): ResourceContents = TextResourceContents | BlobResourceContents -class EmbeddedResourceContentBlock[TResourceContents: ResourceContents = ResourceContents]( - BaseContentBlock -): +class EmbeddedResourceContentBlock(BaseContentBlock): """Complete resource contents embedded directly in the message. Preferred for including context as it avoids extra round-trips. @@ -90,7 +88,7 @@ class EmbeddedResourceContentBlock[TResourceContents: ResourceContents = Resourc type: Literal["resource"] = Field(default="resource", init=False) - resource: TResourceContents + resource: ResourceContents """Resource content that can be embedded in a message.""" diff --git a/src/acp/schema/elicitation.py b/src/acp/schema/elicitation.py new file mode 100644 index 000000000..049ba2cb3 --- /dev/null +++ b/src/acp/schema/elicitation.py @@ -0,0 +1,436 @@ +"""Elicitation schema definitions. + +**UNSTABLE**: This module is not part of the spec yet, and may be removed or changed at any point. + +Defines types for agent-initiated elicitation, where the agent requests +structured input from the user via forms or URLs. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Annotated, Any, Literal, Self + +from pydantic import Discriminator, Field, Tag + +from acp.schema.base import AnnotatedObject, Request, Response + + +# --- String format --- + + +class StringFormat: + """String format types for string properties in elicitation schemas.""" + + EMAIL: Literal["email"] = "email" + URI: Literal["uri"] = "uri" + DATE: Literal["date"] = "date" + DATE_TIME: Literal["date-time"] = "date-time" + + +StringFormatLiteral = Literal["email", "uri", "date", "date-time"] + + +# --- Enum option --- + + +class EnumOption(AnnotatedObject): + """A titled enum option with a const value and human-readable title.""" + + const: str + """The constant value for this option.""" + + title: str + """Human-readable title for this option.""" + + +# --- Property schemas --- + + +class StringPropertySchema(AnnotatedObject): + """Schema for string properties in an elicitation form. + + When ``enum_values`` or ``one_of`` is set, this represents a single-select enum. + """ + + type: Literal["string"] = "string" + """Type discriminator.""" + + title: str | None = None + """Optional title for the property.""" + + description: str | None = None + """Human-readable description.""" + + min_length: int | None = Field(default=None, ge=0) + """Minimum string length.""" + + max_length: int | None = Field(default=None, ge=0) + """Maximum string length.""" + + pattern: str | None = None + """Pattern the string must match.""" + + format: StringFormatLiteral | None = None + """String format.""" + + default: str | None = None + """Default value.""" + + enum: Sequence[str] | None = None + """Enum values for untitled single-select enums.""" + + one_of: Sequence[EnumOption] | None = None + """Titled enum options for titled single-select enums.""" + + +class NumberPropertySchema(AnnotatedObject): + """Schema for number (floating-point) properties in an elicitation form.""" + + type: Literal["number"] = "number" + """Type discriminator.""" + + title: str | None = None + """Optional title for the property.""" + + description: str | None = None + """Human-readable description.""" + + minimum: float | None = None + """Minimum value (inclusive).""" + + maximum: float | None = None + """Maximum value (inclusive).""" + + default: float | None = None + """Default value.""" + + +class IntegerPropertySchema(AnnotatedObject): + """Schema for integer properties in an elicitation form.""" + + type: Literal["integer"] = "integer" + """Type discriminator.""" + + title: str | None = None + """Optional title for the property.""" + + description: str | None = None + """Human-readable description.""" + + minimum: int | None = None + """Minimum value (inclusive).""" + + maximum: int | None = None + """Maximum value (inclusive).""" + + default: int | None = None + """Default value.""" + + +class BooleanPropertySchema(AnnotatedObject): + """Schema for boolean properties in an elicitation form.""" + + type: Literal["boolean"] = "boolean" + """Type discriminator.""" + + title: str | None = None + """Optional title for the property.""" + + description: str | None = None + """Human-readable description.""" + + default: bool | None = None + """Default value.""" + + +# --- Multi-select items --- + + +class UntitledMultiSelectItems(AnnotatedObject): + """Items definition for untitled multi-select enum properties.""" + + type: Literal["string"] = "string" + """Item type discriminator. Must be ``"string"``.""" + + enum: Sequence[str] + """Allowed enum values.""" + + +class TitledMultiSelectItems(AnnotatedObject): + """Items definition for titled multi-select enum properties.""" + + any_of: Sequence[EnumOption] + """Titled enum options.""" + + +def _multi_select_items_discriminator(v: Any) -> str: + if isinstance(v, dict): + if "anyOf" in v or "any_of" in v: + return "titled" + return "untitled" + if isinstance(v, TitledMultiSelectItems): + return "titled" + return "untitled" + + +MultiSelectItems = Annotated[ + Annotated[UntitledMultiSelectItems, Tag("untitled")] + | Annotated[TitledMultiSelectItems, Tag("titled")], + Discriminator(_multi_select_items_discriminator), +] +"""Items for a multi-select (array) property schema.""" + + +class MultiSelectPropertySchema(AnnotatedObject): + """Schema for multi-select (array) properties in an elicitation form.""" + + type: Literal["array"] = "array" + """Type discriminator.""" + + title: str | None = None + """Optional title for the property.""" + + description: str | None = None + """Human-readable description.""" + + min_items: int | None = Field(default=None, ge=0) + """Minimum number of items to select.""" + + max_items: int | None = Field(default=None, ge=0) + """Maximum number of items to select.""" + + items: MultiSelectItems + """The items definition describing allowed values.""" + + default: Sequence[str] | None = None + """Default selected values.""" + + +# --- Property schema union --- + + +def _property_schema_discriminator(v: Any) -> str: + if isinstance(v, dict): + return v.get("type", "string") # type: ignore[no-any-return] + return v.type # type: ignore[no-any-return] + + +ElicitationPropertySchema = Annotated[ + Annotated[StringPropertySchema, Tag("string")] + | Annotated[NumberPropertySchema, Tag("number")] + | Annotated[IntegerPropertySchema, Tag("integer")] + | Annotated[BooleanPropertySchema, Tag("boolean")] + | Annotated[MultiSelectPropertySchema, Tag("array")], + Discriminator(_property_schema_discriminator), +] +"""Property schema for elicitation form fields. + +Each variant corresponds to a JSON Schema ``"type"`` value. +""" + + +# --- Elicitation schema --- + + +class ElicitationSchema(AnnotatedObject): + """Type-safe elicitation schema for requesting structured user input. + + This represents a JSON Schema object with primitive-typed properties, + as required by the elicitation specification. + """ + + type: Literal["object"] = "object" + """Type discriminator. Always ``"object"``.""" + + title: str | None = None + """Optional title for the schema.""" + + description: str | None = None + """Optional description of what this schema represents.""" + + properties: dict[str, ElicitationPropertySchema] = Field(default_factory=dict) + """Property definitions (must be primitive types).""" + + required: Sequence[str] | None = None + """List of required property names.""" + + +# --- Elicitation content value --- + +ElicitationContentValue = str | int | float | bool | Sequence[str] +"""Possible value types in elicitation content.""" + + +# --- Elicitation actions --- + + +class ElicitationAcceptAction(AnnotatedObject): + """**UNSTABLE**: The user accepted the elicitation and provided content.""" + + action: Literal["accept"] = "accept" + """Discriminator value.""" + + content: dict[str, ElicitationContentValue] | None = None + """The user-provided content, if any, as an object matching the requested schema.""" + + +class ElicitationDeclineAction(AnnotatedObject): + """**UNSTABLE**: The user declined the elicitation.""" + + action: Literal["decline"] = "decline" + """Discriminator value.""" + + +class ElicitationCancelAction(AnnotatedObject): + """**UNSTABLE**: The elicitation was cancelled.""" + + action: Literal["cancel"] = "cancel" + """Discriminator value.""" + + +def _elicitation_action_discriminator(v: Any) -> str: + if isinstance(v, dict): + return v.get("action", "accept") # type: ignore[no-any-return] + return v.action # type: ignore[no-any-return] + + +ElicitationAction = Annotated[ + Annotated[ElicitationAcceptAction, Tag("accept")] + | Annotated[ElicitationDeclineAction, Tag("decline")] + | Annotated[ElicitationCancelAction, Tag("cancel")], + Discriminator(_elicitation_action_discriminator), +] +"""The user's action in response to an elicitation.""" + + +# --- Elicitation capabilities --- + + +class ElicitationFormCapabilities(AnnotatedObject): + """**UNSTABLE**: Form-based elicitation capabilities.""" + + +class ElicitationUrlCapabilities(AnnotatedObject): + """**UNSTABLE**: URL-based elicitation capabilities.""" + + +class ElicitationCapabilities(AnnotatedObject): + """**UNSTABLE**: Elicitation capabilities supported by the client.""" + + form: ElicitationFormCapabilities | None = None + """Whether the client supports form-based elicitation.""" + + url: ElicitationUrlCapabilities | None = None + """Whether the client supports URL-based elicitation.""" + + +# --- Elicitation modes --- + + +class ElicitationFormMode(AnnotatedObject): + """**UNSTABLE**: Form-based elicitation mode. + + The client renders a form from the provided schema. + """ + + mode: Literal["form"] = "form" + """Discriminator value.""" + + requested_schema: ElicitationSchema + """A JSON Schema describing the form fields to present to the user.""" + + +class ElicitationUrlMode(AnnotatedObject): + """**UNSTABLE**: URL-based elicitation mode. + + The client directs the user to a URL. + """ + + mode: Literal["url"] = "url" + """Discriminator value.""" + + elicitation_id: str + """The unique identifier for this elicitation.""" + + url: str + """The URL to direct the user to.""" + + +def _elicitation_mode_discriminator(v: Any) -> str: + if isinstance(v, dict): + return v.get("mode", "form") # type: ignore[no-any-return] + return v.mode # type: ignore[no-any-return] + + +ElicitationMode = Annotated[ + Annotated[ElicitationFormMode, Tag("form")] | Annotated[ElicitationUrlMode, Tag("url")], + Discriminator(_elicitation_mode_discriminator), +] +"""The mode of elicitation.""" + + +# --- Elicitation request/response --- + + +class ElicitationRequest(Request): + """**UNSTABLE**: Request from the agent to elicit structured user input. + + The agent sends this to the client to request information from the user, + either via a form or by directing them to a URL. + """ + + session_id: str + """The session ID for this request.""" + + message: str + """A human-readable message describing what input is needed.""" + + mode: ElicitationMode + """The elicitation mode and its mode-specific fields.""" + + @classmethod + def form( + cls, + session_id: str, + message: str, + schema: ElicitationSchema, + ) -> Self: + """Create a form-based elicitation request.""" + return cls( + session_id=session_id, + message=message, + mode=ElicitationFormMode(requested_schema=schema), + ) + + @classmethod + def url_based( + cls, + session_id: str, + message: str, + elicitation_id: str, + url: str, + ) -> Self: + """Create a URL-based elicitation request.""" + return cls( + session_id=session_id, + message=message, + mode=ElicitationUrlMode(elicitation_id=elicitation_id, url=url), + ) + + +class ElicitationResponse(Response): + """**UNSTABLE**: Response from the client to an elicitation request.""" + + action: ElicitationAction + """The user's action in response to the elicitation.""" + + +# --- Elicitation complete notification --- + + +class ElicitationCompleteNotification(AnnotatedObject): + """**UNSTABLE**: Notification sent by the agent when a URL-based elicitation is complete.""" + + elicitation_id: str + """The ID of the elicitation that completed.""" diff --git a/src/acp/schema/field_meta.py b/src/acp/schema/field_meta.py new file mode 100644 index 000000000..3ec1fb6f5 --- /dev/null +++ b/src/acp/schema/field_meta.py @@ -0,0 +1,406 @@ +"""Undocumented ``_meta`` field conventions used by ACP implementations. + +The ACP protocol includes a generic ``_meta`` (``field_meta``) extension point on +most schema objects (via :class:`~acp.schema.base.AnnotatedObject`). Several +implementations — notably ``claude-agent-acp``, ``codex-acp``, and Zed — have +established conventions for what goes into these fields. None of these are part +of the official ACP specification. + +This module provides typed dictionaries documenting every known convention so +that implementors can produce and consume them with type safety rather than +relying on raw ``dict[str, Any]`` access. + +Sources: + - claude-agent-acp: ``src/acp-agent.ts`` (ToolUpdateMeta, NewSessionMeta, GatewayAuthMeta) + - Zed: ``crates/agent_servers/src/acp.rs`` (terminal meta consumption, terminal-auth) + - Zed: ``crates/acp_thread/src/acp_thread.rs`` (tool_name, subagent_session_info) +""" + +from __future__ import annotations + +from typing import Any, TypedDict + + +# --------------------------------------------------------------------------- +# Tool call / tool_call_update meta (_meta on ToolCallStart / ToolCallProgress) +# --------------------------------------------------------------------------- + + +class ClaudeCodeToolMeta(TypedDict, total=False): + """Claude Code-specific metadata attached to tool call updates. + + Produced by ``claude-agent-acp`` in ``streamEventToAcpNotifications()`` + (``src/acp-agent.ts:1760-1890``). + + Consumed by Zed indirectly — Zed currently reads ``tool_name`` from the + top-level meta, not from this nested object. + """ + + toolName: str + """The name of the tool as known to Claude Code (e.g. ``Bash``, ``Edit``, ``Read``). + + Source: ``claude-agent-acp/src/acp-agent.ts:178`` + """ + + toolResponse: Any + """Structured output from the tool execution. + + For the ``Edit`` tool this contains the full structured patch with + ``filePath`` and ``structuredPatch`` fields, processed by + ``toolUpdateFromEditToolResponse()`` (``claude-agent-acp/src/tools.ts:698``). + + Source: ``claude-agent-acp/src/acp-agent.ts:180`` + """ + + parentToolUseId: str + """When a tool call is made inside a sub-agent (e.g. via the ``Agent`` / ``Task`` + tool), this field links back to the parent tool use that spawned the sub-agent. + + Set in ``toAcpNotifications()`` when ``options.parentToolUseId`` is provided + (``claude-agent-acp/src/acp-agent.ts:1696-1701``). + + Not yet consumed by Zed as of the current codebase. + + Source: ``claude-agent-acp/src/acp-agent.ts:1681`` + """ + + +class TerminalInfoMeta(TypedDict): + """Metadata to request creation of a display-only terminal in the client. + + Sent on the initial ``tool_call`` session update for ``Bash`` tools when + the client advertises ``terminal_output`` support in its capabilities meta. + + The client should create a display-only terminal (no real PTY) identified + by ``terminal_id`` to render streaming output. + + Producer: ``claude-agent-acp/src/acp-agent.ts:1821-1823`` + Consumer: ``zed/crates/agent_servers/src/acp.rs:1244-1272`` + Creates a ``TerminalBuilder::new_display_only`` and registers it via + ``TerminalProviderEvent::Created``. + """ + + terminal_id: str + """Unique identifier for the terminal session. + + Typically reuses the Claude Code tool use ID (``chunk.id``). + """ + + +class TerminalOutputMeta(TypedDict): + """Metadata carrying terminal output data for a display-only terminal. + + Sent as a separate ``tool_call_update`` notification between the initial + ``tool_call`` (which creates the terminal) and the final ``tool_call_update`` + (which carries the exit status). + + Producer: ``claude-agent-acp/src/acp-agent.ts:1860-1875`` + ``claude-agent-acp/src/tools.ts:505-510`` + Consumer: ``zed/crates/agent_servers/src/acp.rs:1290-1302`` + Feeds data into the terminal via ``TerminalProviderEvent::Output``. + """ + + terminal_id: str + """The terminal to write output to. Must match a previously created terminal.""" + + data: str + """Raw terminal output as a string (stdout/stderr combined).""" + + +class TerminalExitMeta(TypedDict): + """Metadata signaling that a terminal process has exited. + + Sent on the final ``tool_call_update`` for Bash tools alongside the + ``completed`` / ``failed`` status. + + Producer: ``claude-agent-acp/src/acp-agent.ts:1883-1886`` + ``claude-agent-acp/src/tools.ts:511-515`` + Consumer: ``zed/crates/agent_servers/src/acp.rs:1306-1330`` + Signals exit via ``TerminalProviderEvent::Exit`` with exit code and signal. + """ + + terminal_id: str + """The terminal that exited. Must match a previously created terminal.""" + + exit_code: int + """Process exit code (0 = success).""" + + signal: str | None + """Signal that terminated the process, or ``None`` if exited normally.""" + + +class ToolUpdateMeta(TypedDict, total=False): + """Complete ``_meta`` shape for ``tool_call`` and ``tool_call_update`` session updates. + + This is the top-level ``_meta`` object attached to + :class:`~acp.schema.session_updates.ToolCallStart` and + :class:`~acp.schema.session_updates.ToolCallProgress` notifications. + + Originally defined as ``ToolUpdateMeta`` in ``claude-agent-acp/src/acp-agent.ts:176``. + The same terminal meta conventions are used by ``codex-acp`` — see the comment at + ``claude-agent-acp/src/acp-agent.ts:183``: + *"Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol."* + + **Lifecycle for Bash tools with terminal support** (3 notifications): + + 1. ``tool_call`` with ``terminal_info`` → client creates display-only terminal + 2. ``tool_call_update`` with ``terminal_output`` → client feeds output data + 3. ``tool_call_update`` with ``terminal_exit`` → client marks process exited + + This workaround exists because Claude Code and Codex execute bash commands + server-side, but ACP normally expects the client to manage terminal processes + via ``terminal/create``. The ``_meta`` fields enable terminal-like UI rendering + in clients even though the process runs remotely. + + See: ``claude-agent-acp/src/acp-agent.ts:1855-1860`` (lifecycle comment) + """ + + claudeCode: ClaudeCodeToolMeta + """Claude Code-specific tool metadata.""" + + terminal_info: TerminalInfoMeta + """Present on initial ``tool_call`` for Bash tools. Signals the client to + create a display-only terminal widget.""" + + terminal_output: TerminalOutputMeta + """Present on ``tool_call_update`` to stream terminal output to the client.""" + + terminal_exit: TerminalExitMeta + """Present on final ``tool_call_update`` to signal process exit.""" + + +# --------------------------------------------------------------------------- +# Tool call top-level meta (meta on ToolCall / ToolCallStart, not _meta) +# --------------------------------------------------------------------------- +# These are conventions for the ACP-level ``meta`` field on ToolCall objects, +# separate from the ``_meta`` extension point. + + +class ToolCallMeta(TypedDict, total=False): + """Conventions for the ``meta`` field on :class:`~acp.schema.tool_call.ToolCall`. + + These are used by Zed's own agents (not claude-agent-acp) to pass + structured information alongside tool calls. + + Source: ``zed/crates/acp_thread/src/acp_thread.rs:39-75`` + """ + + tool_name: str + """The underlying tool name, used by Zed to label tool calls in the UI. + + Extracted via ``tool_name_from_meta()`` in + ``zed/crates/acp_thread/src/acp_thread.rs:42-47``. + Created via ``meta_with_tool_name()`` at line 50-51. + """ + + subagent_session_info: SubagentSessionInfoMeta + """Metadata linking a tool call to a sub-agent session. + + Extracted via ``subagent_session_info_from_meta()`` in + ``zed/crates/acp_thread/src/acp_thread.rs:69-72``. + Set in ``zed/crates/agent/src/tools/spawn_agent_tool.rs:155-236``. + """ + + +class SubagentSessionInfoMeta(TypedDict): + """Links a tool call to the sub-agent session it spawned. + + Stored as a JSON value under the ``subagent_session_info`` key in the + tool call's ``meta`` field. Used by Zed to enable navigation into + sub-agent conversation threads. + + Source: ``zed/crates/acp_thread/src/acp_thread.rs:57-66`` + """ + + session_id: str + """The session ID of the spawned sub-agent session.""" + + message_start_index: int + """Index of the first message in the sub-agent's turn.""" + + message_end_index: int | None + """Index of the last message returned by the sub-agent, or ``None`` + if the sub-agent has not yet completed.""" + + +# --------------------------------------------------------------------------- +# Client capabilities meta (_meta on ClientCapabilities) +# --------------------------------------------------------------------------- + + +class ClientCapabilitiesMeta(TypedDict, total=False): + """Conventions for ``_meta`` on :class:`~acp.schema.capabilities.ClientCapabilities`. + + Sent during ``initialize`` to advertise non-standard client features. + + Source: ``zed/crates/agent_servers/src/acp.rs:286-289`` + Consumed by: ``claude-agent-acp/src/acp-agent.ts:973`` and ``:1686`` + """ + + terminal_output: bool + """When ``True``, the client supports rendering terminal output streamed + via :class:`ToolUpdateMeta` ``terminal_info`` / ``terminal_output`` / + ``terminal_exit`` fields. + + Without this, agents fall back to sending bash output as plain text + content blocks. + + Set by Zed at ``crates/agent_servers/src/acp.rs:287``. + Checked by claude-agent-acp at ``src/acp-agent.ts:973``: + ``clientCapabilities?._meta?.["terminal_output"] === true`` + """ + + +class TerminalAuthValue(TypedDict): + """Value for the ``terminal-auth`` key in :class:`ClientCapabilitiesMeta`. + + When set to ``True`` (boolean), it signals the client supports terminal-based + authentication. When set to an object (for Gemini workaround), it contains + the command to spawn for authentication. + + Source: ``zed/crates/agent_servers/src/acp.rs:288`` (boolean ``true``) + Source: ``zed/crates/agent_servers/src/acp.rs:325-335`` (Gemini workaround object) + """ + + label: str + """Human-readable label for the auth command.""" + + command: str + """Path to the executable to run for authentication.""" + + args: list[str] + """Arguments to pass to the command.""" + + env: dict[str, str] + """Environment variables to set when running the command.""" + + +# --------------------------------------------------------------------------- +# Auth capabilities meta (_meta on AuthCapabilities) +# --------------------------------------------------------------------------- + + +class AuthCapabilitiesMeta(TypedDict, total=False): + """Conventions for ``_meta`` on :class:`~acp.schema.capabilities.AuthCapabilities`. + + Checked by ``claude-agent-acp`` to decide whether to offer gateway auth. + + Source: ``claude-agent-acp/src/acp-agent.ts:285-286`` + """ + + gateway: bool + """When ``True``, the client supports the ``gateway`` authentication method, + which redirects API calls through a custom base URL with injected headers. + + Checked at ``claude-agent-acp/src/acp-agent.ts:286``: + ``request.clientCapabilities?.auth?._meta?.gateway === true`` + """ + + +# --------------------------------------------------------------------------- +# Auth method meta (_meta on AuthMethod) +# --------------------------------------------------------------------------- + + +class TerminalAuthMethodMeta(TypedDict): + """Meta on an :class:`~acp.schema.common.AuthMethodTerminal` for terminal-based auth. + + Used by Zed as a workaround for agents (like Gemini) that need a CLI + command spawned for authentication. + + Source: ``zed/crates/agent_servers/src/acp.rs:325-335`` + """ + + terminal_auth: TerminalAuthValue # note: wire format uses "terminal-auth" + """Command specification for terminal-based authentication. + + Note: The wire format key is ``terminal-auth`` (with hyphen). The Python + field name uses an underscore for identifier compatibility. + """ + + +# --------------------------------------------------------------------------- +# New session meta (_meta on NewSessionRequest / session/new) +# --------------------------------------------------------------------------- + + +class ClaudeCodeSessionOptions(TypedDict, total=False): + """Claude Code SDK options forwarded via session creation meta. + + These are passed through to the Claude Code SDK's ``Options`` type. + Some parameters are managed by the ACP adapter and will be ignored + if provided (``cwd``, ``permissionMode``, ``executable``, etc.). + + Source: ``claude-agent-acp/src/acp-agent.ts:136-156`` + """ + + resume: bool + """Whether to resume a previous Claude Code session.""" + + hooks: dict[str, Any] + """Hook definitions, merged with ACP's own hooks.""" + + mcpServers: list[dict[str, Any]] + """MCP server configurations, merged with ACP's own servers.""" + + disallowedTools: list[str] + """Tool names to disallow, merged with ACP's own disallowed tools.""" + + tools: list[dict[str, Any]] + """Tool definitions passed through to Claude Code. + Defaults to the ``claude_code`` preset if not provided.""" + + +class ClaudeCodeNewSessionMeta(TypedDict, total=False): + """Claude Code-specific metadata nested under ``claudeCode``.""" + + options: ClaudeCodeSessionOptions + """Options forwarded to the Claude Code SDK.""" + + +class NewSessionMeta(TypedDict, total=False): + """``_meta`` shape for ``session/new`` requests. + + Allows clients to pass implementation-specific options when creating + a new session with a Claude Code ACP agent. + + Source: ``claude-agent-acp/src/acp-agent.ts:136-156`` + Consumed at: ``claude-agent-acp/src/acp-agent.ts:368`` and ``:1205`` + """ + + claudeCode: ClaudeCodeNewSessionMeta + """Claude Code-specific session creation options.""" + + +# --------------------------------------------------------------------------- +# Gateway authentication meta (_meta on authenticate request) +# --------------------------------------------------------------------------- + + +class GatewayConfig(TypedDict): + """Gateway configuration for routing API calls through a custom proxy. + + Source: ``claude-agent-acp/src/acp-agent.ts:160-170`` + """ + + baseUrl: str + """Base URL to redirect API calls to.""" + + headers: dict[str, str] + """Custom headers to inject into API requests.""" + + +class GatewayAuthMeta(TypedDict): + """``_meta`` shape for ``authenticate`` requests using the ``gateway`` method. + + When a client selects the ``gateway`` authentication method, it sends this + metadata to configure API call routing through a custom endpoint. The agent + maps these to environment variables that override the default Anthropic API + configuration. + + Source: ``claude-agent-acp/src/acp-agent.ts:158-170`` + Consumed at: ``claude-agent-acp/src/acp-agent.ts:459`` + """ + + gateway: GatewayConfig + """Gateway routing configuration.""" diff --git a/src/acp/schema/messages.py b/src/acp/schema/messages.py index 532fbfd48..9084342a1 100644 --- a/src/acp/schema/messages.py +++ b/src/acp/schema/messages.py @@ -21,6 +21,7 @@ AgentMethod = Literal[ "authenticate", "initialize", + "logout", "session/cancel", "session/load", "session/new", @@ -30,12 +31,14 @@ "session/list", "session/fork", "session/resume", - "session/stop", + "session/close", ] ClientMethod = Literal[ "fs/read_text_file", "fs/write_text_file", + "session/elicitation", + "session/elicitation/complete", "session/request_permission", "session/update", "terminal/create", diff --git a/src/acp/schema/notifications.py b/src/acp/schema/notifications.py index 00e27c5fd..8cdff942d 100644 --- a/src/acp/schema/notifications.py +++ b/src/acp/schema/notifications.py @@ -2,18 +2,14 @@ from __future__ import annotations -from typing import Any, Generic, TypeVar +from typing import Any from acp.schema.base import AnnotatedObject -from acp.schema.session_updates import SessionUpdate +from acp.schema.elicitation import ElicitationCompleteNotification +from acp.schema.session_updates import SessionUpdate # noqa: TC001 -TSessionUpdate_co = TypeVar( - "TSessionUpdate_co", covariant=True, bound=SessionUpdate, default=SessionUpdate -) - - -class SessionNotification(AnnotatedObject, Generic[TSessionUpdate_co]): +class SessionNotification(AnnotatedObject): """Notification containing a session update from the agent. Used to stream real-time progress and results during prompt processing. @@ -24,7 +20,7 @@ class SessionNotification(AnnotatedObject, Generic[TSessionUpdate_co]): session_id: str """The ID of the session this update pertains to.""" - update: TSessionUpdate_co + update: SessionUpdate """The session update data.""" @@ -65,7 +61,7 @@ class ExtNotification(AnnotatedObject): """Optional parameters for the notification.""" -AgentNotification = SessionNotification | ExtNotification +AgentNotification = SessionNotification | ElicitationCompleteNotification | ExtNotification """All possible notifications that an agent can send to a client. This is used internally for routing RPC notifications. diff --git a/src/acp/schema/session_state.py b/src/acp/schema/session_state.py index b28c931ec..7756c4544 100644 --- a/src/acp/schema/session_state.py +++ b/src/acp/schema/session_state.py @@ -2,43 +2,12 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, Literal - -from pydantic import Field +from collections.abc import Sequence # noqa: TC003 +from typing import Any from acp.schema.base import AnnotatedObject -# Type aliases for config option identifiers -SessionConfigId = str -"""Unique identifier for a configuration option.""" - -SessionConfigValueId = str -"""Unique identifier for a possible value within a configuration option.""" - -SessionConfigGroupId = str -"""Unique identifier for a group of values within a configuration option.""" - - -SessionConfigOptionCategory = Literal["mode", "model", "thought_level", "other"] -"""**UNSTABLE**: This capability is not part of the spec yet. - -Semantic category for a session configuration option. - -This is intended to help Clients distinguish broadly common selectors (e.g. model selector vs -session mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons, -placement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown -categories gracefully (treat as `other`). - -Values: - - "mode": Session mode selector - - "model": Model selector - - "thought_level": Thought/reasoning level selector - - "other": Unknown / uncategorized selector -""" - - class ModelInfo(AnnotatedObject): """**UNSTABLE**: This capability is not part of the spec yet. @@ -114,84 +83,3 @@ class SessionInfo(AnnotatedObject): meta: dict[str, Any] | None = None """Arbitrary session metadata.""" - - -class SessionConfigSelectOption(AnnotatedObject): - """A possible value for a configuration selector.""" - - value: SessionConfigValueId - """Unique identifier for this option value.""" - - name: str - """Human-readable label for this option value.""" - - description: str | None = None - """Optional description for this option value.""" - - -class SessionConfigSelectGroup(AnnotatedObject): - """A group of possible values for a configuration selector.""" - - group: SessionConfigGroupId - """Unique identifier for this group.""" - - name: str - """Human-readable label for this group.""" - - options: Sequence[SessionConfigSelectOption] - """The set of option values in this group.""" - - -SessionConfigSelectOptions = ( - Sequence[SessionConfigSelectOption] | Sequence[SessionConfigSelectGroup] -) -"""The possible values for a configuration selector, optionally organized into groups.""" - - -class SessionConfigSelect(AnnotatedObject): - """A single-value selector (dropdown) session configuration option payload.""" - - current_value: SessionConfigValueId - """The currently selected value.""" - - options: SessionConfigSelectOptions - """The set of selectable options.""" - - -class SessionConfigKind(AnnotatedObject): - """Type-specific session configuration option payload.""" - - type: Literal["select"] = Field(default="select", init=False) - """Discriminator for the config option type.""" - - # Flattened SessionConfigSelect fields - current_value: SessionConfigValueId - """The currently selected value.""" - - options: SessionConfigSelectOptions - """The set of selectable options.""" - - -class SessionConfigOption(AnnotatedObject): - """A session configuration option selector and its current state.""" - - id: SessionConfigId - """Unique identifier for the configuration option.""" - - name: str - """Human-readable label for the option.""" - - description: str | None = None - """Optional description for the Client to display to the user.""" - - category: SessionConfigOptionCategory | None = None - """Optional semantic category for this option (UX only).""" - - type: Literal["select"] = Field(default="select", init=False) - """Discriminator for the config option type (flattened from kind).""" - - current_value: SessionConfigValueId - """The currently selected value (flattened from kind.select).""" - - options: SessionConfigSelectOptions - """The set of selectable options (flattened from kind.select).""" diff --git a/src/acp/schema/session_updates.py b/src/acp/schema/session_updates.py index c5c1d1394..5690ea854 100644 --- a/src/acp/schema/session_updates.py +++ b/src/acp/schema/session_updates.py @@ -10,6 +10,7 @@ from acp.schema.agent_plan import PlanEntry # noqa: TC001 from acp.schema.base import AnnotatedObject +from acp.schema.config_options import SessionConfigOption # noqa: TC001 from acp.schema.content_blocks import ( # noqa: TC001 Annotations, Audience, @@ -22,7 +23,6 @@ TextContentBlock, TextResourceContents, ) -from acp.schema.session_state import SessionConfigOption # noqa: TC001 from acp.schema.slash_commands import AvailableCommand # noqa: TC001 from acp.schema.tool_call import ( # noqa: TC001 ToolCallContent, @@ -229,7 +229,7 @@ def embedded_text_resource( ) contents = TextResourceContents(text=text, mime_type=mime_type, uri=uri) content = EmbeddedResourceContentBlock(annotations=annotations, resource=contents) - return cls(content=content, message_id=message_id) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + return cls(content=content, message_id=message_id) @classmethod def embedded_blob_resource( @@ -263,7 +263,7 @@ def embedded_blob_resource( ) resource = BlobResourceContents(blob=data, mime_type=mime_type, uri=uri) content = EmbeddedResourceContentBlock(annotations=annotations, resource=resource) - return cls(content=content, message_id=message_id) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + return cls(content=content, message_id=message_id) class UserMessageChunk(BaseChunk): @@ -363,38 +363,21 @@ class AvailableCommandsUpdate(AnnotatedObject): """Commands the agent can execute""" -class CurrentModelUpdate(AnnotatedObject): - """**UNSTABLE**: This capability is not part of the spec yet. - - The current model of the session has changed. - """ - - current_model_id: str - """The ID of the current model.""" - - session_update: Literal["current_model_update"] = Field( - default="current_model_update", init=False - ) - - class ConfigOptionUpdate(AnnotatedObject): """A session configuration option value has changed. - See protocol docs: [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) + The full set of configuration options and their current values is sent + on every change. Clients should replace their cached list entirely. + + See protocol docs: `Session Config Options `_ """ session_update: Literal["config_option_update"] = Field( default="config_option_update", init=False ) - config_id: str - """The ID of the configuration option that changed.""" - - value_id: str - """The new value ID for this configuration option.""" - config_options: Sequence[SessionConfigOption] - """The full list of config options with updated values.""" + """The full set of configuration options and their current values.""" class ToolCallStart(AnnotatedObject): @@ -517,7 +500,6 @@ class SessionInfoUpdate(AnnotatedObject): | AvailableCommandsUpdate | AgentPlanUpdate | CurrentModeUpdate - | CurrentModelUpdate | ConfigOptionUpdate | SessionInfoUpdate | UsageUpdate diff --git a/src/acp/schema/tool_call.py b/src/acp/schema/tool_call.py index 9b815a165..04653f07c 100644 --- a/src/acp/schema/tool_call.py +++ b/src/acp/schema/tool_call.py @@ -101,13 +101,13 @@ class TerminalToolCallContent(Schema): """The ID of the terminal being embedded.""" -class ContentToolCallContent[TContentBlock: ContentBlock = ContentBlock](Schema): +class ContentToolCallContent(Schema): """Standard content block (text, images, resources).""" type: Literal["content"] = Field(default="content", init=False) """Standard content block (text, images, resources).""" - content: TContentBlock + content: ContentBlock """The actual content block.""" @classmethod @@ -261,7 +261,7 @@ def embedded_text_resource( ) contents = TextResourceContents(text=text, mime_type=mime_type, uri=uri) content = EmbeddedResourceContentBlock(annotations=annotations, resource=contents) - return cls(content=content) # ty: ignore[invalid-argument-type] + return cls(content=content) @classmethod def embedded_blob_resource( @@ -292,7 +292,7 @@ def embedded_blob_resource( ) resource = BlobResourceContents(blob=data, mime_type=mime_type, uri=uri) content = EmbeddedResourceContentBlock(annotations=annotations, resource=resource) - return cls(content=content) # ty: ignore[invalid-argument-type] + return cls(content=content) class ToolCallLocation(AnnotatedObject): diff --git a/src/acp/stdio.py b/src/acp/stdio.py index 36ae4f042..a870766fb 100644 --- a/src/acp/stdio.py +++ b/src/acp/stdio.py @@ -37,7 +37,7 @@ async def receive(self, max_bytes: int = 65536) -> bytes: loop = asyncio.get_running_loop() # read1() returns immediately when any data is available (up to max_bytes) # unlike read() which blocks until exactly max_bytes are read or EOF - data: bytes = await loop.run_in_executor(None, sys.stdin.buffer.read1, max_bytes) # type: ignore[union-attr] + data: bytes = await loop.run_in_executor(None, sys.stdin.buffer.read1, max_bytes) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] if not data: raise anyio.EndOfStream return data @@ -228,7 +228,7 @@ def create_agent(conn: AgentSideConnection) -> MyAgent: # Wrap agent instance in factory if needed if callable(agent): - agent_factory = agent # pyright: ignore[reportAssignmentType] + agent_factory = agent else: def agent_factory(connection: AgentSideConnection) -> Agent: diff --git a/src/acp/tool_call_reporter.py b/src/acp/tool_call_reporter.py index 1a624202a..be8e30809 100644 --- a/src/acp/tool_call_reporter.py +++ b/src/acp/tool_call_reporter.py @@ -67,7 +67,7 @@ def __init__( self.kind: ToolCallKind | None = kind self.status: ToolCallStatus = status self.locations: list[ToolCallLocation] = list(locations) if locations else [] - self.content: list[ToolCallContent] = list(content) if content else [] + self.content: list[ToolCallContent | str] = list(content) if content else [] self.raw_input = raw_input self.raw_output = raw_output self._started = False @@ -140,12 +140,12 @@ async def update( if content is not None: content_list: list[ToolCallContent | str] = list(content) if replace: - self.content = content_list # type: ignore[assignment] + self.content = content_list else: - self.content.extend(content_list) # type: ignore[arg-type] + self.content.extend(content_list) if message is not None: - self.content.append(message) # type: ignore[arg-type] + self.content.append(message) await self._notifications.tool_call_progress( tool_call_id=self.tool_call_id, diff --git a/src/acp/transports.py b/src/acp/transports.py index a2cbf393a..cd74ee602 100644 --- a/src/acp/transports.py +++ b/src/acp/transports.py @@ -316,7 +316,7 @@ def _ensure_factory( # Wrap instance in factory def factory(connection: AgentSideConnection) -> Agent: - return agent # type: ignore[return-value] + return agent # type: ignore[return-value] # ty:ignore[invalid-return-type] return factory diff --git a/src/acp/utils.py b/src/acp/utils.py index c600e409f..0740128c1 100644 --- a/src/acp/utils.py +++ b/src/acp/utils.py @@ -5,8 +5,7 @@ import base64 from typing import TYPE_CHECKING, Any -from pydantic_ai import BinaryContent, ToolReturn -from pydantic_ai.messages import AudioUrl, DocumentUrl, ImageUrl, VideoUrl +from pydantic_ai import AudioUrl, BinaryContent, DocumentUrl, ImageUrl, ToolReturn, VideoUrl from acp.schema import ( AudioContentBlock, diff --git a/src/agentpool/agents/acp_agent/acp_agent.py b/src/agentpool/agents/acp_agent/acp_agent.py index c51fcc042..29d95a1b3 100644 --- a/src/agentpool/agents/acp_agent/acp_agent.py +++ b/src/agentpool/agents/acp_agent/acp_agent.py @@ -30,7 +30,6 @@ import asyncio import contextlib -from dataclasses import replace from datetime import datetime import os from pathlib import Path @@ -39,18 +38,13 @@ import anyio from pydantic import HttpUrl -from pydantic_ai import ModelRequest, ModelResponse, TextPart, UserPromptPart from acp import InitializeRequest from acp.agent import ACPAgentAPI from agentpool.agents.acp_agent.session_state import ACPSessionState from agentpool.agents.base_agent import BaseAgent -from agentpool.agents.events import ( - RunStartedEvent, - StreamCompleteEvent, - ToolCallCompleteEvent, -) -from agentpool.agents.events.processors import event_to_part +from agentpool.agents.events import RunStartedEvent, StreamCompleteEvent +from agentpool.agents.events.reconstructor import MessageReconstructor from agentpool.agents.exceptions import ( AgentNotInitializedError, UnknownCategoryError, @@ -70,7 +64,7 @@ from anyio.abc import Process from evented_config import EventConfig from exxec import ExecutionEnvironment - from pydantic_ai import ThinkingPart, ToolCallPart, UserContent + from pydantic_ai import UserContent from slashed import BaseCommand from tokonomics.model_discovery.model_info import ModelInfo @@ -319,18 +313,18 @@ async def __aexit__( async def _resolve_command(self) -> list[str]: """Resolve the command to run, either from explicit command or registry.""" + from acp.registry import fetch_agent + if self._command: return [self._command, *self._args] # Registry-based resolution assert self._registry_id is not None - from acp.registry import fetch_agent, prepare_agent - agent = await fetch_agent(self._registry_id) if agent is None: raise RuntimeError(f"Agent {self._registry_id!r} not found in ACP registry") # Merge registry env vars (agent-specific take precedence) self._env_vars = {**agent.dist.env, **self._env_vars} - return await prepare_agent(agent, self._args) + return await agent.prepare(self._args) async def _start_process(self) -> Process: """Start the ACP server subprocess.""" @@ -418,7 +412,7 @@ async def _cleanup(self) -> None: self.log.exception("Error terminating ACP process") self._process = None - async def _stream_events( # noqa: PLR0915 + async def _stream_events( self, prompts: list[UserContent], *, @@ -448,11 +442,11 @@ async def _stream_events( # noqa: PLR0915 run_id = str(uuid.uuid4()) self._state.clear() - model_messages: list[ModelResponse | ModelRequest] = [] - initial_request = ModelRequest(parts=[UserPromptPart(content=prompts)]) - model_messages.append(initial_request) - current_response_parts: list[TextPart | ThinkingPart | ToolCallPart] = [] - text_chunks: list[str] = [] + reconstructor = MessageReconstructor( + initial_prompts=prompts, + model_name=self.model_name, + provider_name=self._provider_type, + ) assert self.session_id is not None yield RunStartedEvent(session_id=self.session_id, run_id=run_id, agent_name=self.name) # Persist SDK session ID to storage for cross-referencing @@ -470,69 +464,43 @@ async def _stream_events( # noqa: PLR0915 prompt_task = asyncio.create_task(self._api.prompt(session_id, final_blocks)) self._prompt_task = prompt_task - async def poll_acp_events() -> AsyncIterator[RichAgentStreamEvent[str]]: - """Poll raw updates from ACP state, convert to events, until prompt completes.""" - from agentpool.agents.acp_agent.acp_converters import acp_to_native_event - - assert self._state - while not prompt_task.done(): - if self._client_handler: - try: - await self._client_handler._update_event.wait_with_timeout(0.05) - self._client_handler._update_event.clear() - except TimeoutError: - pass - while (update := self._state.pop_update()) is not None: - if native_event := acp_to_native_event(update): - yield native_event - while (update := self._state.pop_update()) is not None: - if native_event := acp_to_native_event(update): - yield native_event + from agentpool.agents.acp_agent.stream_adapter import AcpAgentStreamedResponse + + assert self._client_handler + streamed_response = AcpAgentStreamedResponse( + state=self._state, + update_event=self._client_handler._update_event, + prompt_task=prompt_task, + agent_name=self.name, + tool_metadata=self._tool_bridge.tool_metadata, + ) try: async with ( self._tool_bridge.set_run_context(run_context, prompt=prompts), - merge_queue_into_iterator(poll_acp_events(), self._event_queue) as merged_events, # ty: ignore[invalid-argument-type] + merge_queue_into_iterator(streamed_response, self._event_queue) as merged_events, # ty: ignore[invalid-argument-type] ): async for event in merged_events: if self._cancelled: self.log.info("Stream cancelled by user") break - if isinstance(event, ToolCallCompleteEvent): - enriched_event = event - if not enriched_event.agent_name: - enriched_event = replace(enriched_event, agent_name=self.name) - if ( - enriched_event.metadata is None - and enriched_event.tool_call_id in self._tool_bridge.tool_metadata - ): - enriched_event = replace( - enriched_event, - metadata=self._tool_bridge.tool_metadata[ - enriched_event.tool_call_id - ], - ) - event = enriched_event # noqa: PLW2901 - part = event_to_part(event) # ty: ignore[invalid-argument-type] - if isinstance(part, TextPart): - text_chunks.append(part.content) - if part: - current_response_parts.append(part) - yield event + reconstructor.observe(event) # ty: ignore[invalid-argument-type] + yield event # ty:ignore[invalid-yield] except asyncio.CancelledError: self.log.info("Stream cancelled via task cancellation") self._cancelled = True if self._cancelled: + reconstructor.flush() message = ChatMessage[str]( - content="".join(text_chunks), + content=reconstructor.text_content, role="assistant", name=self.name, message_id=message_id or str(uuid.uuid4()), session_id=self.session_id, parent_id=user_msg.message_id, model_name=self.model_name, - messages=model_messages, + messages=reconstructor.model_messages, finish_reason="stop", ) yield StreamCompleteEvent(message=message) @@ -541,20 +509,12 @@ async def poll_acp_events() -> AsyncIterator[RichAgentStreamEvent[str]]: response = await prompt_task finish_reason = to_finish_reason(response.stop_reason) - if current_response_parts: - model_messages.append( - ModelResponse( - parts=current_response_parts, - finish_reason=finish_reason, - model_name=self.model_name, - provider_name=self._provider_type, - ) - ) + reconstructor.flush(finish_reason=finish_reason) - text_content = "".join(text_chunks) + text_content = reconstructor.text_content usage, cost_info = await calculate_usage_from_parts( input_parts=prompts, - response_parts=current_response_parts, + response_parts=reconstructor.all_response_parts, text_content=text_content, model_name=self.model_name, provider=self._provider_type, @@ -568,7 +528,7 @@ async def poll_acp_events() -> AsyncIterator[RichAgentStreamEvent[str]]: session_id=self.session_id, parent_id=user_msg.message_id, model_name=self.model_name, - messages=model_messages, + messages=reconstructor.model_messages, finish_reason=finish_reason, usage=usage, cost_info=cost_info, @@ -622,24 +582,24 @@ async def get_available_models(self) -> list[ModelInfo] | None: async def get_modes(self) -> list[ModeCategory]: """Get available modes from the ACP session state.""" - from agentpool.agents.acp_agent.acp_converters import get_modes + from agentpool.agents.acp_agent.acp_converters import to_native_modes if not self._state: return [] - return get_modes( + return to_native_modes( self._state.config_options, available_modes=self._state.modes, available_models=self._state.models, ) - async def _set_mode(self, mode_id: str, category_id: str) -> None: + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: """Forward mode change to remote ACP server.""" if not self._api or not self._sdk_session_id or not self._state: raise RuntimeError("Not connected to ACP server") available_modes = await self.get_modes() if matching_category := next((c for c in available_modes if c.id == category_id), None): - valid_ids = {m.id for m in matching_category.available_modes} + valid_ids = {str(m.value) for m in matching_category.available_modes} if mode_id not in valid_ids: raise UnknownModeError(mode_id, sorted(valid_ids)) else: @@ -653,10 +613,12 @@ async def _set_mode(self, mode_id: str, category_id: str) -> None: if response and response.config_options: self._state.config_options = list(response.config_options) elif category_id == "mode": + assert isinstance(mode_id, str) await self._api.set_session_mode(self._sdk_session_id, mode_id) if self._state.modes: self._state.modes.current_mode_id = mode_id elif category_id == "model": + assert isinstance(mode_id, str) if await self._api.set_session_model(self._sdk_session_id, mode_id): self._state.current_model_id = mode_id self.log.info("Model changed via legacy set_session_model") diff --git a/src/agentpool/agents/acp_agent/acp_converters.py b/src/agentpool/agents/acp_agent/acp_converters.py index d4787187d..8f508a127 100644 --- a/src/agentpool/agents/acp_agent/acp_converters.py +++ b/src/agentpool/agents/acp_agent/acp_converters.py @@ -12,6 +12,7 @@ from __future__ import annotations import base64 +from collections.abc import Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Literal, assert_never, overload from uuid import uuid4 @@ -20,19 +21,25 @@ AudioUrl, BinaryContent, BinaryImage, + BuiltinToolCallPart, + BuiltinToolReturnPart, CachePoint, DocumentUrl, + FilePart, ImageUrl, ModelRequest, ModelResponse, + RetryPromptPart, + SystemPromptPart, + TextContent, TextPart, ThinkingPart, ToolCallPart, ToolReturnPart, + UploadedFile, UserPromptPart, VideoUrl, ) -from pydantic_ai.messages import UploadedFile from acp.schema import ( AgentMessageChunk, @@ -45,13 +52,17 @@ FileEditToolCallContent, ImageContentBlock, ResourceContentBlock, + SelectSessionConfigOption, SessionConfigSelectOption, TerminalToolCallContent, TextContentBlock, + TextResourceContents, + ToolCallLocation, ToolCallProgress, ToolCallStart, UserMessageChunk, ) +from acp.utils import generate_tool_title, infer_tool_kind, to_acp_content_blocks from agentpool.agents.events import ( DiffContentItem, LocationContentItem, @@ -62,10 +73,11 @@ ToolCallProgressEvent, ToolCallStartEvent, ) +from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable, Iterator, Sequence from pydantic_ai import FinishReason, ModelMessage, ModelResponsePart, UserContent @@ -81,7 +93,6 @@ StdioMcpServer, StopReason, ToolCallContent, - ToolCallLocation, ) from agentpool.agents.events import RichAgentStreamEvent, ToolCallContentItem from agentpool.agents.modes import ModeCategory, ModeInfo @@ -102,7 +113,150 @@ } -def get_modes( +def model_messages_to_session_updates( + messages: Sequence[ModelMessage], +) -> Iterator[SessionUpdate]: + """Convert pydantic-ai ModelMessages to ACP SessionUpdate objects. + + This is a pure conversion function with no I/O. It yields one or more + SessionUpdate instances for each message part. + + Args: + messages: Sequence of pydantic-ai model messages to convert. + + Yields: + SessionUpdate instances ready to be sent via a client. + """ + from pydantic_ai import TextPart, ThinkingPart, ToolCallPart + + tool_call_inputs: dict[str, dict[str, Any]] = {} + for message in messages: + for part in message.parts: + match part: + case TextPart(content=content): + yield AgentMessageChunk.text(text=content) + + case ThinkingPart(content=content): + yield AgentThoughtChunk.text(text=content) + + case ( + ToolCallPart(tool_call_id=tool_call_id, tool_name=tool_name) + | BuiltinToolCallPart(tool_call_id=tool_call_id, tool_name=tool_name) + ): + tool_input = safe_args_as_dict(part) + tool_call_inputs[tool_call_id] = tool_input + title = generate_tool_title(tool_name, tool_input) + yield ToolCallStart( + tool_call_id=tool_call_id, + status="pending", + title=title, + kind=infer_tool_kind(tool_name), + raw_input=tool_input, + ) + + case FilePart(content=content) if content.is_image: + yield AgentMessageChunk.image( + data=content.data, + mime_type=content.media_type, + ) + case FilePart(content=content) if content.is_audio: + yield AgentMessageChunk.audio( + data=content.data, + mime_type=content.media_type, + ) + case FilePart(): + pass + + case UserPromptPart(content=str(content)): + yield UserMessageChunk.text(text=content) + + case UserPromptPart(content=content): + yield from _user_content_to_updates(content) + + case ( + ToolReturnPart(content=content, tool_name=tool_name, tool_call_id=tool_call_id) + | BuiltinToolReturnPart( + content=content, tool_name=tool_name, tool_call_id=tool_call_id + ) + ): + converted = to_acp_content_blocks(content) + tool_input = tool_call_inputs.get(tool_call_id, {}) + acp_content = [ContentToolCallContent(content=block) for block in converted] + locations = [ + ToolCallLocation(path=value) + for key, value in tool_input.items() + if key in {"path", "file_path", "filepath"} and isinstance(value, str) + ] + title = generate_tool_title(tool_name, tool_input) + yield ToolCallProgress( + tool_call_id=tool_call_id, + title=title, + status="completed", + locations=locations or None, + content=acp_content or None, + raw_output=converted, + ) + tool_call_inputs.pop(tool_call_id, None) + + case SystemPromptPart() | RetryPromptPart(): + pass + case _ as unreachable: + assert_never(unreachable) + + +def _user_content_to_updates(content: Any) -> Iterator[SessionUpdate]: + """Convert multi-modal user content to ACP session updates.""" + converted_content = to_acp_content_blocks(content) + for block in converted_content: + match block: + case TextContentBlock(text=text): + yield UserMessageChunk.text(text=text) + case ImageContentBlock(annotations=annots) as img_block: + yield UserMessageChunk.image( + data=img_block.data, + mime_type=img_block.mime_type, + uri=img_block.uri, + audience=annots.audience if annots else None, + last_modified=annots.last_modified if annots else None, + priority=annots.priority if annots else None, + ) + case AudioContentBlock(annotations=annots) as audio_block: + yield UserMessageChunk.audio( + data=audio_block.data, + mime_type=audio_block.mime_type, + audience=annots.audience if annots else None, + last_modified=annots.last_modified if annots else None, + priority=annots.priority if annots else None, + ) + case ResourceContentBlock(annotations=annots) as resource_block: + yield UserMessageChunk.resource( + uri=resource_block.uri, + name=resource_block.name, + description=resource_block.description, + mime_type=resource_block.mime_type, + size=resource_block.size, + title=resource_block.title, + audience=annots.audience if annots else None, + last_modified=annots.last_modified if annots else None, + priority=annots.priority if annots else None, + ) + case EmbeddedResourceContentBlock(resource=resource): + match resource: + case TextResourceContents(text=text): + yield UserMessageChunk.text(text=text) + case BlobResourceContents(blob=blob, mime_type=mime_type): + blob_size = len(blob) * 3 // 4 + size_mb = blob_size / (1024 * 1024) + mime = mime_type or "unknown" + msg = f"Embedded resource: {mime} ({size_mb:.2f} MB)" + yield UserMessageChunk.text(text=msg) + case _ as unreachable: + assert_never(unreachable) # ty: ignore[type-assertion-failure] + case _ as unreachable: + assert_never(unreachable) + + +def to_native_modes( config_options: list[SessionConfigOption], available_modes: SessionModeState | None, available_models: SessionModelState | None, @@ -113,13 +267,16 @@ def get_modes( if config_options: for config_opt in config_options: + # Skip boolean config options - they don't map to mode categories + if not isinstance(config_opt, SelectSessionConfigOption): + continue # Extract options from the config (ungrouped or grouped) mode_infos: list[ModeInfo] = [] for i in config_opt.options: opts = [i] if isinstance(i, SessionConfigSelectOption) else i.options mode_infos.extend( ModeInfo( - id=sub_opt.value, + value=sub_opt.value, name=sub_opt.name, description=sub_opt.description or "", category_id=config_opt.id, @@ -132,7 +289,7 @@ def get_modes( id=config_opt.id, name=config_opt.name, available_modes=mode_infos, - current_mode_id=config_opt.current_value, + current_mode_id=str(config_opt.current_value), category=config_opt.category or "other", ) ) @@ -142,7 +299,7 @@ def get_modes( if available_modes: modes = [ ModeInfo( - id=m.id, + value=m.id, name=m.name, description=m.description or "", category_id="mode", @@ -163,7 +320,7 @@ def get_modes( if available_models: models = [ ModeInfo( - id=m.model_id, + value=m.model_id, name=m.name, description=m.description or "", category_id="model", @@ -228,7 +385,7 @@ def convert_to_acp_content(prompts: Sequence[UserContent]) -> list[ContentBlock] for item in prompts: match item: - case str(text): + case str(text) | TextContent(content=text): content_blocks.append(TextContentBlock(text=text)) case BinaryImage(data=data, media_type=media_type): diff --git a/src/agentpool/agents/acp_agent/client_handler.py b/src/agentpool/agents/acp_agent/client_handler.py index e4a9b121e..85c2277b2 100644 --- a/src/agentpool/agents/acp_agent/client_handler.py +++ b/src/agentpool/agents/acp_agent/client_handler.py @@ -43,6 +43,11 @@ WaitForTerminalExitRequest, WriteTextFileRequest, ) + from acp.schema.elicitation import ( + ElicitationCompleteNotification, + ElicitationRequest, + ElicitationResponse, + ) from agentpool.agents.acp_agent import ACPAgent from agentpool.agents.acp_agent.session_state import ACPSessionState from agentpool.ui.base import InputProvider @@ -112,7 +117,7 @@ def allow_terminal(self) -> bool: caps = self._agent._init_request.client_capabilities return bool(caps and caps.terminal) - async def session_update(self, params: SessionNotification[Any]) -> None: + async def session_update(self, params: SessionNotification) -> None: """Handle session update notifications from the agent. Some updates are state changes (mode, model, config) that should update @@ -122,12 +127,9 @@ async def session_update(self, params: SessionNotification[Any]) -> None: Raw updates are stored as the single source of truth. Conversion to native events happens lazily during streaming consumption. """ - from tokonomics.model_discovery.model_info import ModelInfo - from acp.schema import ( AvailableCommandsUpdate, ConfigOptionUpdate, - CurrentModelUpdate, CurrentModeUpdate, ) from agentpool.agents.modes import ModeInfo @@ -142,7 +144,7 @@ async def session_update(self, params: SessionNotification[Any]) -> None: (m for m in self.state.modes.available_modes if m.id == mode_id), None ): mode_info = ModeInfo( - id=acp_mode.id, + value=acp_mode.id, name=acp_mode.name, description=acp_mode.description or "", category_id="mode", # Old modes API is for operational modes @@ -151,32 +153,20 @@ async def session_update(self, params: SessionNotification[Any]) -> None: self.state.current_mode_id = mode_id logger.debug("Mode updated", mode_id=mode_id) self._update_event.set() - return - - case CurrentModelUpdate(current_model_id=model_id): - self.state.current_model_id = model_id - if state := self.state.models: - state.current_model_id = model_id - # Find ModelInfo and emit signal - if m := next( - (m for m in state.available_models if m.model_id == model_id), None - ): - info = ModelInfo(id=m.model_id, name=m.name, description=m.description) - await self._agent.state_updated.emit(info) - logger.debug("Model updated", model_id=model_id) - self._update_event.set() - return - - case ConfigOptionUpdate(config_id=config_id, value_id=value_id): - # Update the config option in state - for config_opt in self.state.config_options: - if config_opt.id == config_id: - config_opt.current_value = value_id - break - await self._agent.update_state(config_id=config_id, value_id=value_id) + case ConfigOptionUpdate(config_options=new_options): + # Detect changes by comparing with current state + old_by_id = {o.id: o.current_value for o in self.state.config_options} + # Replace full config options list + self.state.config_options = list(new_options) + # Emit change signals for each option whose value changed + for opt in new_options: + old_val = old_by_id.get(opt.id) + if old_val != opt.current_value: + await self._agent.update_state( + config_id=str(opt.id), + value_id=str(opt.current_value), + ) self._update_event.set() - return - case AvailableCommandsUpdate() as update: self.state.available_commands = update # Populate command store with remote commands @@ -187,18 +177,16 @@ async def session_update(self, params: SessionNotification[Any]) -> None: await self._agent.state_updated.emit(update) logger.debug("Available commands updated", count=len(update.available_commands)) self._update_event.set() - return - - # TODO: AgentPlanUpdate handling is complex and needs design work. - # Options: - # 1. Update pool.todos - requires merging with existing todos - # 2. Pass through to UI - but then todos aren't centrally managed - # 3. Switch to agent-owned todos instead of pool-owned - # For now, AgentPlanUpdate falls through to stream data. - - # Store raw update - conversion happens lazily during consumption - self.state.add_update(params.update) - self._update_event.set() + case _: + # TODO: AgentPlanUpdate handling is complex and needs design work. + # Options: + # 1. Update pool.todos - requires merging with existing todos + # 2. Pass through to UI - but then todos aren't centrally managed + # 3. Switch to agent-owned todos instead of pool-owned + # For now, AgentPlanUpdate falls through to stream data. + + self.state.add_update(params.update) + self._update_event.set() async def request_permission( # noqa: PLR0911 self, params: RequestPermissionRequest @@ -400,6 +388,15 @@ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any logger.debug("Extension method called", method=method) return {"ok": True, "method": method} + async def elicitation(self, params: ElicitationRequest) -> ElicitationResponse: + """Decline elicitation by default.""" + from acp.schema.elicitation import ElicitationDeclineAction, ElicitationResponse + + return ElicitationResponse(action=ElicitationDeclineAction()) + + async def elicitation_complete(self, params: ElicitationCompleteNotification) -> None: + """Ignore elicitation complete notifications.""" + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: """Handle extension notifications.""" logger.debug("Extension notification", method=method) diff --git a/src/agentpool/agents/acp_agent/modes.py b/src/agentpool/agents/acp_agent/modes.py deleted file mode 100644 index f0d2762a1..000000000 --- a/src/agentpool/agents/acp_agent/modes.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Mode categories for ACPAgent.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar - -from agentpool.agents.exceptions import UnknownModeError -from agentpool.agents.modes import ConfigOptionChanged, ModeCategoryProtocol, ModeInfo - - -if TYPE_CHECKING: - from acp.schema import SessionConfigOption, SessionModelState, SessionModeState - from agentpool.agents.acp_agent.acp_agent import ACPAgent - - -# ============================================================================= -# Mode category implementations -# ============================================================================= - - -@dataclass -class ACPModeCategory(ModeCategoryProtocol["ACPAgent"]): - """Mode category for ACP - handles permissions/approval policy. - - Automatically uses config_options API if available, falls back to - set_session_mode for older servers. - """ - - available_modes: list[ModeInfo] - - id: ClassVar[str] = "mode" - name: ClassVar[str] = "Mode" - category: ClassVar[str] = "mode" - - def get_current(self, agent: ACPAgent) -> str: - """Get current mode from ACP state.""" - # Try config_options first - if agent._state and agent._state.config_options: - for opt in agent._state.config_options: - if opt.id == "mode": - return opt.current_value - # Fall back to legacy modes state - if agent._state and agent._state.modes: - return agent._state.modes.current_mode_id - return "" - - async def apply(self, agent: ACPAgent, mode_id: str) -> None: - """Apply mode change - uses config_options if available, else legacy API.""" - valid_ids = {m.id for m in self.available_modes} - if mode_id not in valid_ids: - raise UnknownModeError(mode_id, list(valid_ids)) - - if not agent._connection or not agent._sdk_session_id or not agent._state: - raise RuntimeError("Not connected to ACP server") - - # Try config_options API first - if agent._state.config_options: - await self._apply_via_config_options(agent, mode_id) - else: - await self._apply_via_legacy(agent, mode_id) - - await agent.update_state(config_id=self.id, value_id=mode_id) - - async def _apply_via_config_options(self, agent: ACPAgent, mode_id: str) -> None: - """Apply using new config_options API.""" - from acp.schema import SetSessionConfigOptionRequest - - assert agent._connection is not None - assert agent._state is not None - assert agent._sdk_session_id is not None - - config_request = SetSessionConfigOptionRequest( - session_id=agent._sdk_session_id, - config_id="mode", - value=mode_id, - ) - response = await agent._connection.set_session_config_option(config_request) - - if response.config_options: - agent._state.config_options = list(response.config_options) - - agent.log.info("Mode changed via config_options", mode_id=mode_id) - - async def _apply_via_legacy(self, agent: ACPAgent, mode_id: str) -> None: - """Apply using legacy set_session_mode API.""" - from acp.schema import SetSessionModeRequest - - assert agent._connection is not None - assert agent._state is not None - assert agent._sdk_session_id is not None - - mode_request = SetSessionModeRequest(session_id=agent._sdk_session_id, mode_id=mode_id) - await agent._connection.set_session_mode(mode_request) - - if agent._state.modes: - agent._state.modes.current_mode_id = mode_id - - agent.log.info("Mode changed via legacy API", mode_id=mode_id) - - @classmethod - def from_state( - cls, - config_options: list[SessionConfigOption] | None, - modes_state: SessionModeState | None, - ) -> ACPModeCategory | None: - """Create from ACP state - prefers config_options, falls back to modes_state. - - Args: - config_options: SessionConfigOption list (new API) - modes_state: SessionModeState (legacy API) - - Returns: - ACPModeCategory instance, or None if no mode info available - """ - from acp.schema import SessionConfigSelectGroup - - # Try config_options first - if config_options: - for config_opt in config_options: - if config_opt.id != "mode": - continue - mode_infos: list[ModeInfo] = [] - for opt_item in config_opt.options: - if isinstance(opt_item, SessionConfigSelectGroup): - mode_infos.extend( - ModeInfo( - id=sub_opt.value, - name=sub_opt.name, - description=sub_opt.description or "", - category_id="mode", - ) - for sub_opt in opt_item.options - ) - else: - mode_infos.append( - ModeInfo( - id=opt_item.value, - name=opt_item.name, - description=opt_item.description or "", - category_id="mode", - ) - ) - return cls(available_modes=mode_infos) - - # Fall back to legacy modes state - if modes_state: - modes = [ - ModeInfo( - id=m.id, - name=m.name, - description=m.description or "", - category_id="mode", - ) - for m in modes_state.available_modes - ] - return cls(available_modes=modes) - - return None - - -@dataclass -class ACPModelCategory(ModeCategoryProtocol["ACPAgent"]): - """Model category for ACP - handles model selection. - - Automatically uses config_options API if available, falls back to - set_session_model for older servers. - """ - - available_modes: list[ModeInfo] - - id: ClassVar[str] = "model" - name: ClassVar[str] = "Model" - category: ClassVar[str] = "model" - - def get_current(self, agent: ACPAgent) -> str: - """Get current model from ACP state.""" - # Try config_options first - if agent._state and agent._state.config_options: - for opt in agent._state.config_options: - if opt.id == "model": - return opt.current_value - # Fall back to legacy models state - if agent._state and agent._state.models: - return agent._state.models.current_model_id - return "" - - async def apply(self, agent: ACPAgent, mode_id: str) -> None: - """Apply model change - uses config_options if available, else legacy API.""" - valid_ids = {m.id for m in self.available_modes} - if mode_id not in valid_ids: - raise UnknownModeError(mode_id, list(valid_ids)) - - if not agent._connection or not agent._sdk_session_id or not agent._state: - raise RuntimeError("Not connected to ACP server") - - # Try config_options API first - if agent._state.config_options: - await self._apply_via_config_options(agent, mode_id) - else: - await self._apply_via_legacy(agent, mode_id) - await agent.update_state(config_id=self.id, value_id=mode_id) - - async def _apply_via_config_options(self, agent: ACPAgent, mode_id: str) -> None: - """Apply using new config_options API.""" - from acp.schema import SetSessionConfigOptionRequest - - assert agent._connection is not None - assert agent._state is not None - assert agent._sdk_session_id is not None - - config_request = SetSessionConfigOptionRequest( - session_id=agent._sdk_session_id, - config_id="model", - value=mode_id, - ) - response = await agent._connection.set_session_config_option(config_request) - - if response.config_options: - agent._state.config_options = list(response.config_options) - - agent.log.info("Model changed via config_options", model_id=mode_id) - - async def _apply_via_legacy(self, agent: ACPAgent, mode_id: str) -> None: - """Apply using legacy set_session_model API.""" - from acp.schema import SetSessionModelRequest - - assert agent._connection is not None - assert agent._state is not None - assert agent._sdk_session_id is not None - - request = SetSessionModelRequest(session_id=agent._sdk_session_id, model_id=mode_id) - if await agent._connection.set_session_model(request): - agent._state.current_model_id = mode_id - agent.log.info("Model changed via legacy API", model_id=mode_id) - else: - msg = ( - "Remote ACP agent does not support model changes. " - "set_session_model returned no response." - ) - raise RuntimeError(msg) - - @classmethod - def from_state( - cls, - config_options: list[SessionConfigOption] | None, - models_state: SessionModelState | None, - ) -> ACPModelCategory | None: - """Create from ACP state - prefers config_options, falls back to models_state. - - Args: - config_options: SessionConfigOption list (new API) - models_state: SessionModelState (legacy API) - - Returns: - ACPModelCategory instance, or None if no model info available - """ - from acp.schema import SessionConfigSelectGroup - - # Try config_options first - if config_options: - for config_opt in config_options: - if config_opt.id != "model": - continue - mode_infos: list[ModeInfo] = [] - if isinstance(config_opt.options, list): - for opt_item in config_opt.options: - if isinstance(opt_item, SessionConfigSelectGroup): - mode_infos.extend( - ModeInfo( - id=sub_opt.value, - name=sub_opt.name, - description=sub_opt.description or "", - category_id="model", - ) - for sub_opt in opt_item.options - ) - else: - mode_infos.append( - ModeInfo( - id=opt_item.value, - name=opt_item.name, - description=opt_item.description or "", - category_id="model", - ) - ) - return cls(available_modes=mode_infos) - - # Fall back to legacy models state - if models_state: - models = [ - ModeInfo( - id=m.model_id, - name=m.name, - description=m.description or "", - category_id="model", - ) - for m in models_state.available_models - ] - return cls(available_modes=models) - - return None - - -@dataclass -class ACPGenericCategory(ModeCategoryProtocol["ACPAgent"]): - """Generic category for ACP - handles any config_option that's not mode/model. - - For things like thought_level or custom categories that only exist in - the config_options API. - """ - - id: str - name: str - available_modes: list[ModeInfo] - category: str | None = None - - def get_current(self, agent: ACPAgent) -> str: - """Get current value from ACP state.""" - if agent._state and (opts := agent._state.config_options): - return next((i.current_value for i in opts if i.id == self.id), "") - return "" - - async def apply(self, agent: ACPAgent, mode_id: str) -> None: - """Apply config option change via config_options API.""" - from acp.schema import SetSessionConfigOptionRequest - - valid_ids = {m.id for m in self.available_modes} - if mode_id not in valid_ids: - raise UnknownModeError(mode_id, list(valid_ids)) - - if not agent._connection or not agent._sdk_session_id or not agent._state: - raise RuntimeError("Not connected to ACP server") - - if not agent._state.config_options: - raise RuntimeError(f"Server does not support config_options, cannot set {self.id!r}") - - config_request = SetSessionConfigOptionRequest( - session_id=agent._sdk_session_id, - config_id=self.id, - value=mode_id, - ) - response = await agent._connection.set_session_config_option(config_request) - - if response.config_options: - agent._state.config_options = list(response.config_options) - - agent.log.info("Config option changed", config_id=self.id, value=mode_id) - change = ConfigOptionChanged(config_id=self.id, value_id=mode_id) - await agent.state_updated.emit(change) - - @classmethod - def from_config_options( - cls, - config_options: list[SessionConfigOption], - ) -> list[ACPGenericCategory]: - """Create categories for non-mode/model config options. - - Args: - config_options: SessionConfigOption list - - Returns: - List of ACPGenericCategory for options that aren't mode/model - """ - from acp.schema import SessionConfigSelectGroup - - categories: list[ACPGenericCategory] = [] - - for config_opt in config_options: - # Skip mode and model - they have dedicated categories - if config_opt.id in ("mode", "model"): - continue - - mode_infos: list[ModeInfo] = [] - if isinstance(config_opt.options, list): - for opt_item in config_opt.options: - if isinstance(opt_item, SessionConfigSelectGroup): - mode_infos.extend( - ModeInfo( - id=sub_opt.value, - name=sub_opt.name, - description=sub_opt.description or "", - category_id=config_opt.id, - ) - for sub_opt in opt_item.options - ) - else: - mode_infos.append( - ModeInfo( - id=opt_item.value, - name=opt_item.name, - description=opt_item.description or "", - category_id=config_opt.id, - ) - ) - - categories.append( - cls( - id=config_opt.id, - name=config_opt.name, - available_modes=mode_infos, - category=config_opt.category or "other", - ) - ) - - return categories diff --git a/src/agentpool/agents/acp_agent/stream_adapter.py b/src/agentpool/agents/acp_agent/stream_adapter.py new file mode 100644 index 000000000..e095cfe54 --- /dev/null +++ b/src/agentpool/agents/acp_agent/stream_adapter.py @@ -0,0 +1,78 @@ +"""Stream adapter for converting ACP session updates to agentpool events. + +The ACP agent communicates via a subprocess running the ACP protocol. Session +updates are pushed into an ACPSessionState queue, and this adapter polls that +queue (gated by an asyncio event) until the prompt task completes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any + +from agentpool.utils.streams.streamed_response import StreamedResponse +from agentpool.utils.time_utils import get_now + + +if TYPE_CHECKING: + import asyncio + from collections.abc import AsyncIterator + from datetime import datetime + + from agentpool.agents.acp_agent.client_handler import TimeoutableEvent + from agentpool.agents.acp_agent.session_state import ACPSessionState + from agentpool.agents.events import RichAgentStreamEvent + + +@dataclass(kw_only=True) +class AcpAgentStreamedResponse(StreamedResponse): + """Streamed ACP response that polls session state for updates.""" + + state: ACPSessionState + update_event: TimeoutableEvent + prompt_task: asyncio.Task[Any] + agent_name: str + tool_metadata: dict[str, dict[str, Any]] + _timestamp: datetime = field(default_factory=get_now) + _model_name: str | None = None + + async def _get_event_iterator(self) -> AsyncIterator[RichAgentStreamEvent[str]]: + """Poll raw updates from ACP state, convert to events, until prompt completes.""" + from agentpool.agents.acp_agent.acp_converters import acp_to_native_event + + while not self.prompt_task.done(): + try: + await self.update_event.wait_with_timeout(0.05) + self.update_event.clear() + except TimeoutError: + pass + while (update := self.state.pop_update()) is not None: + if native_event := acp_to_native_event(update): + yield self._enrich(native_event) + # Drain any remaining updates after prompt completes + while (update := self.state.pop_update()) is not None: + if native_event := acp_to_native_event(update): + yield self._enrich(native_event) + + def _enrich(self, event: RichAgentStreamEvent[str]) -> RichAgentStreamEvent[str]: + """Enrich ToolCallCompleteEvents with agent name and tool metadata.""" + from agentpool.agents.events import ToolCallCompleteEvent + + if not isinstance(event, ToolCallCompleteEvent): + return event + if not event.agent_name: + event = replace(event, agent_name=self.agent_name) + if event.metadata is None and event.tool_call_id in self.tool_metadata: + event = replace(event, metadata=self.tool_metadata[event.tool_call_id]) + return event + + @property + def model_name(self) -> str: + """Get the model name of the response.""" + assert self._model_name + return self._model_name + + @property + def timestamp(self) -> datetime: + """Get the timestamp of the response.""" + return self._timestamp diff --git a/src/agentpool/agents/agui_agent/agui_agent.py b/src/agentpool/agents/agui_agent/agui_agent.py index 9f9ea7662..6ad4cf650 100644 --- a/src/agentpool/agents/agui_agent/agui_agent.py +++ b/src/agentpool/agents/agui_agent/agui_agent.py @@ -12,25 +12,18 @@ import asyncio from dataclasses import replace -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self, cast from uuid import uuid4 from anyenv.processes import hard_kill import anyio import httpx -from pydantic_ai import ( - ModelRequest, - ModelResponse, - TextPart, - ThinkingPart, - ToolCallPart, - ToolReturnPart, - UserPromptPart, -) +from pydantic_ai import ModelRequest, ToolReturnPart from agentpool.agents.agui_agent.helpers import execute_tool_call, parse_sse_stream from agentpool.agents.base_agent import BaseAgent -from agentpool.agents.events import RunStartedEvent, StreamCompleteEvent +from agentpool.agents.events import PartStartEvent, RunStartedEvent, StreamCompleteEvent +from agentpool.agents.events.reconstructor import MessageReconstructor from agentpool.agents.exceptions import ( AgentNotInitializedError, OperationNotAllowedError, @@ -262,10 +255,12 @@ async def set_tool_confirmation_mode(self, mode: str) -> None: Raises: ValueError: If mode is not a valid ToolConfirmationMode """ + from agentpool_config.nodes import ToolConfirmationMode + valid_modes: set[str] = {"always", "never", "per_tool"} if mode not in valid_modes: raise UnknownModeError(mode, list(valid_modes)) - self.tool_confirmation_mode = mode # type: ignore[assignment] + self.tool_confirmation_mode = cast(ToolConfirmationMode, mode) self.log.info("Tool confirmation mode changed", mode=mode) async def _interrupt(self) -> None: @@ -307,13 +302,7 @@ async def _stream_events( # noqa: PLR0915 self._sdk_session_id = self.session_id run_id = str(uuid4()) # New run ID for each run - # Track messages in pydantic-ai format: ModelRequest -> ModelResponse -> ModelRequest... - # This mirrors pydantic-ai's new_messages() which includes the initial user request. - model_messages: list[ModelResponse | ModelRequest] = [] - # Start with the user's request (same as pydantic-ai's new_messages()) - initial_request = ModelRequest(parts=[UserPromptPart(content=prompts)]) - model_messages.append(initial_request) - response_parts: list[TextPart | ThinkingPart | ToolCallPart] = [] + reconstructor = MessageReconstructor(initial_prompts=prompts) assert self.session_id is not None # Initialized by BaseAgent.run_stream() thread_id = self._sdk_session_id or self.session_id yield RunStartedEvent(session_id=thread_id, run_id=run_id, agent_name=self.name) @@ -321,7 +310,7 @@ async def _stream_events( # noqa: PLR0915 # AG-UI protocol expects full history with each request (stateless server) # Extract ModelMessages from ChatMessages model_msgs = [m for chat_msg in message_history.get_history() for m in chat_msg.messages] - history_messages = model_messages_to_agui(model_msgs) + history_messages = list(model_messages_to_agui(model_msgs)) # Convert new user message content to AG-UI format final_content = to_agui_input_content(prompts) user_message = UserMessage(id=str(uuid4()), content=final_content) @@ -353,9 +342,9 @@ async def _stream_events( # noqa: PLR0915 response.raise_for_status() async for event in self._process_events( response=response, - response_parts=response_parts, tool_calls_pending=tool_calls_pending, ): + reconstructor.observe(event) yield event except httpx.HTTPError: self.log.exception("HTTP error during AG-UI run") @@ -387,20 +376,16 @@ async def _stream_events( # noqa: PLR0915 # If no results (all tools were server-side), we're done if not pending_tool_results: break - # Flush current response parts to model_messages - if response_parts: - model_messages.append(ModelResponse(parts=response_parts)) - response_parts = [] - # Create ModelRequest with tool return parts - tool_return_parts = [ - ToolReturnPart( - tool_name=tool_calls_pending.get(r.tool_call_id, ("unknown", {}))[0], + # Flush current response parts and add tool returns + reconstructor.flush() + for r in pending_tool_results: + tool_name = tool_calls_pending.get(r.tool_call_id, ("unknown", {}))[0] + return_part = ToolReturnPart( + tool_name=tool_name, content=r.content, tool_call_id=r.tool_call_id, ) - for r in pending_tool_results - ] - model_messages.append(ModelRequest(parts=tool_return_parts)) + reconstructor.model_messages.append(ModelRequest(parts=[return_part])) # Add tool results to messages for next iteration messages = [*pending_tool_results] self.log.debug("Continuing with tool results", count=len(pending_tool_results)) @@ -408,48 +393,41 @@ async def _stream_events( # noqa: PLR0915 self.log.info("Stream cancelled via task cancellation") self._cancelled = True - # Handle cancellation - emit partial message - text = "".join([i.content for i in response_parts if isinstance(i, TextPart)]) + reconstructor.flush() + if self._cancelled: - # Flush any remaining response parts - if response_parts: - model_messages.append(ModelResponse(parts=response_parts)) final_message = ChatMessage[str]( - content=text, + content=reconstructor.text_content, role="assistant", name=self.name, message_id=message_id or str(uuid4()), session_id=self.session_id, parent_id=user_msg.message_id, - messages=model_messages, + messages=reconstructor.model_messages, finish_reason="stop", ) yield StreamCompleteEvent(message=final_message) return - # Flush any remaining response parts - if response_parts: - model_messages.append(ModelResponse(parts=response_parts)) - # Final drain of event queue after stream completes async for e in self._drain_event_queue(): yield e # Calculate approximate token usage from what we can observe usage, cost_info = await calculate_usage_from_parts( input_parts=prompts, - response_parts=response_parts, - text_content=text, + response_parts=reconstructor.all_response_parts, + text_content=reconstructor.text_content, model_name=self.model_name, ) final_message = ChatMessage[str]( - content=text, + content=reconstructor.text_content, role="assistant", name=self.name, message_id=message_id or str(uuid4()), session_id=self.session_id, parent_id=user_msg.message_id, - messages=model_messages, + messages=reconstructor.model_messages, usage=usage, cost_info=cost_info, ) @@ -466,15 +444,9 @@ async def _drain_event_queue(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: async def _process_events( self, response: httpx.Response, - response_parts: list[TextPart | ThinkingPart | ToolCallPart], tool_calls_pending: dict[str, tuple[str, dict[str, Any]]], ) -> AsyncIterator[RichAgentStreamEvent[Any]]: from ag_ui.core import ( - ReasoningMessageChunkEvent, - ReasoningMessageContentEvent, - TextMessageChunkEvent, - TextMessageContentEvent, - ThinkingTextMessageContentEvent, ToolCallArgsEvent as AGUIToolCallArgsEvent, ToolCallEndEvent as AGUIToolCallEndEvent, ToolCallStartEvent as AGUIToolCallStartEvent, @@ -484,7 +456,7 @@ async def _process_events( from agentpool.agents.agui_agent.chunk_transformer import ChunkTransformer from agentpool.agents.tool_call_accumulator import ToolCallAccumulator - tool_accumulator = ToolCallAccumulator() + accumulator = ToolCallAccumulator() chunk_transformer = ChunkTransformer() # Create chunk transformer for this run async for raw_event in parse_sse_stream(response): # Check for cancellation during streaming @@ -493,37 +465,33 @@ async def _process_events( break # Transform chunks to proper START/CONTENT/END sequences for event in chunk_transformer.transform(raw_event): - match event: # Handle events for accumulation and tool calls - case TextMessageContentEvent(delta=delta): - response_parts.append(TextPart(content=delta)) - case TextMessageChunkEvent(delta=delta) if delta: - response_parts.append(TextPart(content=delta)) - case ThinkingTextMessageContentEvent(delta=delta): - response_parts.append(ThinkingPart(content=delta)) - case ReasoningMessageContentEvent(delta=delta): - response_parts.append(ThinkingPart(content=delta)) - case ReasoningMessageChunkEvent(delta=str() as delta): - response_parts.append(ThinkingPart(content=delta)) + match event: # Handle events for tool call accumulation case AGUIToolCallStartEvent(tool_call_id=tc_id, tool_call_name=name) if name: - tool_accumulator.start(tc_id, name) + accumulator.start(tc_id, name) case AGUIToolCallArgsEvent(tool_call_id=tc_id, delta=delta): - tool_accumulator.add_args(tc_id, delta) - case AGUIToolCallEndEvent(tool_call_id=tc_id): - if result := tool_accumulator.complete(tc_id): - tool_name, args = result - tool_calls_pending[tc_id] = (tool_name, args) - p = ToolCallPart(tool_name=tool_name, args=args, tool_call_id=tc_id) - response_parts.append(p) + accumulator.add_args(tc_id, delta) + case AGUIToolCallEndEvent(tool_call_id=tc_id) if result := accumulator.complete( + tc_id + ): + tool_name, args = result + tool_calls_pending[tc_id] = (tool_name, args) + # Emit PartStartEvent so reconstructor can track the tool call + yield PartStartEvent.tool_call( + index=0, + tool_name=tool_name, + args=args, + tool_call_id=tc_id, + ) # Convert to native event and distribute to handlers - if native_event := agui_to_native_event(event): + for native_event in agui_to_native_event(event): async for e in self._drain_event_queue(): yield e yield native_event # Flush any pending chunk events at end of stream for event in chunk_transformer.flush(): - if native_event := agui_to_native_event(event): + for native_event in agui_to_native_event(event): yield native_event @property @@ -559,7 +527,7 @@ async def get_modes(self) -> list[ModeCategory]: """Get available modes for AG-UI agent (not supported).""" return [] - async def _set_mode(self, mode_id: str, category_id: str) -> None: + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: """AG-UI doesn't support mode switching.""" raise OperationNotAllowedError("mode switching (model is controlled by remote server)") diff --git a/src/agentpool/agents/agui_agent/agui_converters.py b/src/agentpool/agents/agui_agent/agui_converters.py index b1be7539d..0f484eee9 100644 --- a/src/agentpool/agents/agui_agent/agui_converters.py +++ b/src/agentpool/agents/agui_agent/agui_converters.py @@ -10,13 +10,29 @@ from __future__ import annotations import base64 -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, assert_never from uuid import uuid4 import anyenv from pydantic_ai import ( + CachePoint, + ModelRequest, + ModelResponse, + SystemPromptPart, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UploadedFile, + UserPromptPart, +) +from pydantic_ai.messages import ( + AudioUrl, BinaryContent, - FileUrl, + DocumentUrl, + ImageUrl, + TextContent, + VideoUrl, ) from agentpool.agents.events import ( @@ -32,16 +48,21 @@ if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterator, Sequence - from ag_ui.core import BaseEvent, InputContent, Message, Tool as AGUITool + from ag_ui.core import ( + Event, + InputContent, + Message, + Tool as AGUITool, + ) from pydantic_ai import ModelMessage, UserContent from agentpool.agents.events import RichAgentStreamEvent from agentpool.tools.base import Tool -def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: # noqa: PLR0911 +def agui_to_native_event(event: Event) -> Iterator[RichAgentStreamEvent[Any]]: """Convert AG-UI event to native streaming event. Args: @@ -64,9 +85,12 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: ReasoningMessageStartEvent, ReasoningStartEvent, RunErrorEvent as AGUIRunErrorEvent, + RunFinishedEvent, RunStartedEvent as AGUIRunStartedEvent, StateDeltaEvent, StateSnapshotEvent, + StepFinishedEvent, + StepStartedEvent, TextMessageChunkEvent, TextMessageContentEvent, TextMessageEndEvent, @@ -87,26 +111,26 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: # === Lifecycle Events === case AGUIRunStartedEvent(thread_id=thread_id, run_id=run_id): - return RunStartedEvent(session_id=thread_id, run_id=run_id) + yield RunStartedEvent(session_id=thread_id, run_id=run_id) case AGUIRunErrorEvent(message=message, code=code): - return RunErrorEvent(message=message, code=code) + yield RunErrorEvent(message=message, code=code) # === Text Message Events === case TextMessageContentEvent(delta=delta) | TextMessageChunkEvent(delta=str() as delta): - return PartDeltaEvent.text(index=0, content=delta) + yield PartDeltaEvent.text(index=0, content=delta) case TextMessageStartEvent() | TextMessageEndEvent(): - return None + pass # === Thinking/Reasoning Events === case ( - ThinkingTextMessageContentEvent(delta=delta) + ThinkingTextMessageContentEvent(delta=delta) # type: ignore[misc] | ReasoningMessageContentEvent(delta=delta) | ReasoningMessageChunkEvent(delta=str() as delta) ): - return PartDeltaEvent.thinking(index=0, content=delta) + yield PartDeltaEvent.thinking(index=0, content=delta) case ( ThinkingStartEvent() @@ -115,7 +139,7 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: | ThinkingTextMessageEndEvent() ): # These mark thinking blocks but don't carry content - return None + pass case ( ReasoningStartEvent() @@ -125,24 +149,24 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: | ReasoningEncryptedValueEvent() ): # These mark reasoning blocks but don't carry streamable content - return None + pass # === Tool Call Events === case ToolCallStartEvent(tool_call_id=str() as tc_id, tool_call_name=name): - return NativeToolCallStartEvent(tool_call_id=tc_id, tool_name=name, title=name) + yield NativeToolCallStartEvent(tool_call_id=tc_id, tool_name=name, title=name) case ToolCallChunkEvent(tool_call_id=str() as tc_id, tool_call_name=str() as name): - return NativeToolCallStartEvent(tool_call_id=tc_id, tool_name=name, title=name) + yield NativeToolCallStartEvent(tool_call_id=tc_id, tool_name=name, title=name) case ToolCallArgsEvent(tool_call_id=tc_id, delta=_): - return ToolCallProgressEvent(tool_call_id=tc_id, status="in_progress") + yield ToolCallProgressEvent(tool_call_id=tc_id, status="in_progress") case ToolCallResultEvent(tool_call_id=tc_id, content=content, message_id=_): - return ToolCallProgressEvent(tool_call_id=tc_id, status="completed", message=content) + yield ToolCallProgressEvent(tool_call_id=tc_id, status="completed", message=content) case ToolCallEndEvent(tool_call_id=tc_id): - return ToolCallProgressEvent(tool_call_id=tc_id, status="completed") + yield ToolCallProgressEvent(tool_call_id=tc_id, status="completed") # === Activity Events -> PlanUpdateEvent === @@ -153,16 +177,16 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: and isinstance(content, list) and (entries := _content_to_plan_entries(content)) ): - return PlanUpdateEvent(entries=entries) + yield PlanUpdateEvent(entries=entries) # For other activity types, wrap as custom event - return CustomEvent( + yield CustomEvent( event_data={"activity_type": activity_type, "content": content}, event_type=f"activity_{activity_type.lower()}", source="ag-ui", ) case ActivityDeltaEvent(activity_type=activity_type, patch=patch): - return CustomEvent( + yield CustomEvent( event_data={"activity_type": activity_type, "patch": patch}, event_type=f"activity_delta_{activity_type.lower()}", source="ag-ui", @@ -171,25 +195,35 @@ def agui_to_native_event(event: BaseEvent) -> RichAgentStreamEvent[Any] | None: # === State Management Events === case StateSnapshotEvent(snapshot=snapshot): - return CustomEvent(event_data=snapshot, event_type="state_snapshot", source="ag-ui") + yield CustomEvent(event_data=snapshot, event_type="state_snapshot", source="ag-ui") case StateDeltaEvent(delta=delta): - return CustomEvent(event_data=delta, event_type="state_delta", source="ag-ui") + yield CustomEvent(event_data=delta, event_type="state_delta", source="ag-ui") case MessagesSnapshotEvent(messages=messages): data = [m.model_dump() for m in messages] - return CustomEvent(event_data=data, event_type="messages_snapshot", source="ag-ui") + yield CustomEvent(event_data=data, event_type="messages_snapshot", source="ag-ui") # === Special Events === case RawEvent(event=raw_event, source=source): - return CustomEvent(event_data=raw_event, event_type="raw", source=source or "ag-ui") + yield CustomEvent(event_data=raw_event, event_type="raw", source=source or "ag-ui") case AGUICustomEvent(name=name, value=value): - return CustomEvent(event_data=value, event_type=name, source="ag-ui") + yield CustomEvent(event_data=value, event_type=name, source="ag-ui") - case _: - return None + case ( + TextMessageChunkEvent() + | ToolCallChunkEvent() + | RunFinishedEvent() + | StepStartedEvent() + | StepFinishedEvent() + | ReasoningMessageChunkEvent() + ): + pass + + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] def _content_to_plan_entries(content: list[Any]) -> list[PlanEntry]: @@ -225,7 +259,15 @@ def _content_to_plan_entries(content: list[Any]) -> list[PlanEntry]: def to_agui_input_content(parts: Sequence[UserContent]) -> list[InputContent]: """Convert pydantic-ai UserContent parts to AG-UI InputContent format.""" - from ag_ui.core import BinaryInputContent, TextInputContent + from ag_ui.core import ( + AudioInputContent, + DocumentInputContent, + ImageInputContent, + InputContentDataSource, + InputContentUrlSource, + TextInputContent, + VideoInputContent, + ) result: list[InputContent] = [] for part in parts: @@ -233,12 +275,45 @@ def to_agui_input_content(parts: Sequence[UserContent]) -> list[InputContent]: case str() as text: result.append(TextInputContent(text=text)) - case FileUrl(url=url, media_type=media_type): - result.append(BinaryInputContent(url=str(url), mime_type=media_type)) + case TextContent(content=text): + result.append(TextInputContent(text=text)) + + case ImageUrl(url=url): + mime = part.media_type or "image/png" + source = InputContentUrlSource(value=str(url), mime_type=mime) + result.append(ImageInputContent(source=source)) + + case AudioUrl(url=url): + mime = part.media_type or "audio/mpeg" + source = InputContentUrlSource(value=str(url), mime_type=mime) + result.append(AudioInputContent(source=source)) + + case VideoUrl(url=url): + mime = part.media_type or "video/mp4" + source = InputContentUrlSource(value=str(url), mime_type=mime) + result.append(VideoInputContent(source=source)) + + case DocumentUrl(url=url): + mime = part.media_type or "application/pdf" + source = InputContentUrlSource(value=str(url), mime_type=mime) + result.append(DocumentInputContent(source=source)) case BinaryContent(data=data, media_type=media_type): encoded = base64.b64encode(data).decode() - result.append(BinaryInputContent(data=encoded, mime_type=media_type)) + mime = media_type or "application/octet-stream" + data_source = InputContentDataSource(value=encoded, mime_type=mime) + if part.is_image: + result.append(ImageInputContent(source=data_source)) + elif part.is_audio: + result.append(AudioInputContent(source=data_source)) + elif part.is_video: + result.append(VideoInputContent(source=data_source)) + else: # aguis BinaryContent is deprecated + result.append(DocumentInputContent(source=data_source)) + case UploadedFile() | CachePoint(): + pass + case _ as unreachable: + assert_never(unreachable) return result @@ -249,12 +324,12 @@ def to_agui_tool(tool: Tool) -> AGUITool: func_schema = tool.schema["function"] return AGUITool( name=func_schema["name"], - description=func_schema.get("description", ""), - parameters=func_schema.get("parameters", {"type": "object", "properties": {}}), + description=func_schema["description"], + parameters=func_schema["parameters"], ) -def model_messages_to_agui(messages: Sequence[ModelMessage]) -> list[Message]: +def model_messages_to_agui(messages: Sequence[ModelMessage]) -> Iterator[Message]: """Convert pydantic-ai ModelMessage sequence to AG-UI Message format. This converts the conversation history from pydantic-ai's internal format @@ -274,18 +349,6 @@ def model_messages_to_agui(messages: Sequence[ModelMessage]) -> list[Message]: ToolMessage, UserMessage, ) - from pydantic_ai import ( - ModelRequest, - ModelResponse, - SystemPromptPart, - TextPart, - ThinkingPart, - ToolCallPart, - ToolReturnPart, - UserPromptPart, - ) - - result: list[Message] = [] for msg in messages: match msg: @@ -294,18 +357,17 @@ def model_messages_to_agui(messages: Sequence[ModelMessage]) -> list[Message]: for req_part in request_parts: match req_part: case UserPromptPart(content=str() as content): - result.append(UserMessage(id=str(uuid4()), content=content)) + yield UserMessage(id=str(uuid4()), content=content) case UserPromptPart(content=list() as content): - # Join text parts - text = " ".join(p if isinstance(p, str) else str(p) for p in content) - result.append(UserMessage(id=str(uuid4()), content=text)) + agui_parts = to_agui_input_content(content) + yield UserMessage(id=str(uuid4()), content=agui_parts) case UserPromptPart(content=content): - result.append(UserMessage(id=str(uuid4()), content=str(content))) + yield UserMessage(id=str(uuid4()), content=str(content)) case SystemPromptPart(content=content): - result.append(SystemMessage(id=str(uuid4()), content=content)) + yield SystemMessage(id=str(uuid4()), content=content) case ToolReturnPart(tool_call_id=tool_call_id, content=content): # Convert content to string @@ -313,12 +375,11 @@ def model_messages_to_agui(messages: Sequence[ModelMessage]) -> list[Message]: content_str = content else: content_str = anyenv.dump_json(content) - tool_msg = ToolMessage( + yield ToolMessage( id=str(uuid4()), tool_call_id=tool_call_id, content=content_str, ) - result.append(tool_msg) case ModelResponse(parts=response_parts): # ModelResponse contains assistant content and/or tool calls @@ -335,29 +396,24 @@ def model_messages_to_agui(messages: Sequence[ModelMessage]) -> list[Message]: if content: text_parts.append(f"[thinking] {content}") - case ToolCallPart( - tool_call_id=tool_call_id, - tool_name=tool_name, - args=args, - ): + case ToolCallPart(tool_call_id=tc_id, tool_name=tool_name, args=args): # Convert args to JSON string match args: case str(): args_str = args case dict(): args_str = anyenv.dump_json(args) - case _: - args_str = str(args) + case None: + args_str = "" + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] call = FunctionCall(name=tool_name, arguments=args_str) - tc = ToolCall(id=tool_call_id, type="function", function=call) + tc = ToolCall(id=tc_id, type="function", function=call) tool_calls.append(tc) # Create AssistantMessage with content and/or tool_calls - assistant_msg = AssistantMessage( + yield AssistantMessage( id=str(uuid4()), content=" ".join(text_parts) if text_parts else None, tool_calls=tool_calls or None, ) - result.append(assistant_msg) - - return result diff --git a/src/agentpool/agents/agui_agent/chunk_transformer.py b/src/agentpool/agents/agui_agent/chunk_transformer.py index 46ee50e10..d2b49ef2c 100644 --- a/src/agentpool/agents/agui_agent/chunk_transformer.py +++ b/src/agentpool/agents/agui_agent/chunk_transformer.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from ag_ui.core import ( - BaseEvent, + Event, ReasoningMessageChunkEvent, TextMessageChunkEvent, ToolCallChunkEvent, @@ -43,7 +43,7 @@ def __init__(self) -> None: # Track active reasoning message: message_id self._active_reasoning: str | None = None - def transform(self, event: BaseEvent) -> list[BaseEvent]: + def transform(self, event: Event) -> list[Event]: """Transform a single event, potentially expanding chunks. Args: @@ -52,28 +52,40 @@ def transform(self, event: BaseEvent) -> list[BaseEvent]: Returns: List of output events (may be empty, single, or multiple) """ - from ag_ui.core import EventType - - match event.type: - case EventType.TEXT_MESSAGE_CHUNK: - return self._handle_text_chunk(event) # type: ignore[arg-type] - - case EventType.TOOL_CALL_CHUNK: - return self._handle_tool_chunk(event) # type: ignore[arg-type] - - case EventType.REASONING_MESSAGE_CHUNK: - return self._handle_reasoning_chunk(event) # type: ignore[arg-type] + from ag_ui.core import ( + ReasoningMessageChunkEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + RunErrorEvent, + RunFinishedEvent, + TextMessageChunkEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallChunkEvent, + ToolCallEndEvent, + ToolCallStartEvent, + ) + + match event: + case TextMessageChunkEvent(): + return self._handle_text_chunk(event) + + case ToolCallChunkEvent(): + return self._handle_tool_chunk(event) + + case ReasoningMessageChunkEvent(): + return self._handle_reasoning_chunk(event) # These events close any pending chunks case ( - EventType.TEXT_MESSAGE_START - | EventType.TEXT_MESSAGE_END - | EventType.TOOL_CALL_START - | EventType.TOOL_CALL_END - | EventType.REASONING_MESSAGE_START - | EventType.REASONING_MESSAGE_END - | EventType.RUN_FINISHED - | EventType.RUN_ERROR + TextMessageStartEvent() + | TextMessageEndEvent() + | ToolCallStartEvent() + | ToolCallEndEvent() + | ReasoningMessageStartEvent() + | ReasoningMessageEndEvent() + | RunFinishedEvent() + | RunErrorEvent() ): close_events = self._close_all_pending() return [*close_events, event] @@ -82,11 +94,11 @@ def transform(self, event: BaseEvent) -> list[BaseEvent]: # Pass through other events unchanged return [event] - def _handle_text_chunk(self, event: TextMessageChunkEvent) -> list[BaseEvent]: + def _handle_text_chunk(self, event: TextMessageChunkEvent) -> list[Event]: """Handle TEXT_MESSAGE_CHUNK event.""" from ag_ui.core import TextMessageContentEvent, TextMessageStartEvent - result: list[BaseEvent] = [] + result: list[Event] = [] message_id = event.message_id role = event.role or "assistant" delta = event.delta @@ -111,11 +123,11 @@ def _handle_text_chunk(self, event: TextMessageChunkEvent) -> list[BaseEvent]: return result - def _handle_tool_chunk(self, event: ToolCallChunkEvent) -> list[BaseEvent]: + def _handle_tool_chunk(self, event: ToolCallChunkEvent) -> list[Event]: """Handle TOOL_CALL_CHUNK event.""" from ag_ui.core import ToolCallArgsEvent, ToolCallStartEvent - result: list[BaseEvent] = [] + result: list[Event] = [] tool_call_id = event.tool_call_id tool_name = event.tool_call_name parent_id = event.parent_message_id @@ -145,7 +157,7 @@ def _handle_tool_chunk(self, event: ToolCallChunkEvent) -> list[BaseEvent]: return result - def _close_text_message(self) -> list[BaseEvent]: + def _close_text_message(self) -> list[Event]: """Close active text message.""" from ag_ui.core import TextMessageEndEvent @@ -159,7 +171,7 @@ def _close_text_message(self) -> list[BaseEvent]: logger.debug("Chunk transformer: TEXT_MESSAGE_END", message_id=message_id) return [end_event] - def _close_tool_call(self) -> list[BaseEvent]: + def _close_tool_call(self) -> list[Event]: """Close active tool call.""" from ag_ui.core import ToolCallEndEvent @@ -172,11 +184,11 @@ def _close_tool_call(self) -> list[BaseEvent]: logger.debug("Chunk transformer: TOOL_CALL_END", tool_call_id=tool_call_id) return [end_event] - def _handle_reasoning_chunk(self, event: ReasoningMessageChunkEvent) -> list[BaseEvent]: + def _handle_reasoning_chunk(self, event: ReasoningMessageChunkEvent) -> list[Event]: """Handle REASONING_MESSAGE_CHUNK event.""" from ag_ui.core import ReasoningMessageContentEvent, ReasoningMessageStartEvent - result: list[BaseEvent] = [] + result: list[Event] = [] message_id = event.message_id delta = event.delta @@ -191,7 +203,7 @@ def _handle_reasoning_chunk(self, event: ReasoningMessageChunkEvent) -> list[Bas # Start new reasoning message if needed if self._active_reasoning is None and message_id: self._active_reasoning = message_id - start_event = ReasoningMessageStartEvent(message_id=message_id, role="assistant") + start_event = ReasoningMessageStartEvent(message_id=message_id, role="reasoning") result.append(start_event) # Emit content if we have delta and active reasoning message @@ -201,7 +213,7 @@ def _handle_reasoning_chunk(self, event: ReasoningMessageChunkEvent) -> list[Bas return result - def _close_reasoning_message(self) -> list[BaseEvent]: + def _close_reasoning_message(self) -> list[Event]: """Close active reasoning message.""" from ag_ui.core import ReasoningMessageEndEvent @@ -215,15 +227,15 @@ def _close_reasoning_message(self) -> list[BaseEvent]: logger.debug("Chunk transformer: REASONING_MESSAGE_END", message_id=message_id) return [end_event] - def _close_all_pending(self) -> list[BaseEvent]: + def _close_all_pending(self) -> list[Event]: """Close all pending chunks (text, tool, and reasoning).""" - result: list[BaseEvent] = [] + result: list[Event] = [] result.extend(self._close_text_message()) result.extend(self._close_tool_call()) result.extend(self._close_reasoning_message()) return result - def flush(self) -> list[BaseEvent]: + def flush(self) -> list[Event]: """Flush any pending events at end of stream. Call this when the stream ends to ensure all pending diff --git a/src/agentpool/agents/base_agent.py b/src/agentpool/agents/base_agent.py index f21de976f..d3da8b52b 100644 --- a/src/agentpool/agents/base_agent.py +++ b/src/agentpool/agents/base_agent.py @@ -20,6 +20,7 @@ from agentpool.agents.events import StreamCompleteEvent, resolve_event_handlers from agentpool.agents.modes import ModeInfo from agentpool.common_types import IndividualEventHandler +from agentpool.hooks import AgentHooks from agentpool.log import get_logger from agentpool.messaging import ChatMessage, MessageHistory, MessageNode from agentpool.prompts.convert import convert_prompts @@ -58,7 +59,6 @@ StrPath, ) from agentpool.delegation import AgentPool, Team, TeamRun - from agentpool.hooks import AgentHooks from agentpool.messaging import ChatMessage from agentpool.sessions import SessionData from agentpool.storage import StorageManager @@ -167,7 +167,7 @@ def __init__( # New shared parameters env: ExecutionEnvironment | StrPath | None = None, input_provider: InputProvider | None = None, - output_type: type[TResult] = str, # type: ignore[assignment] + output_type: type[TResult] = str, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] event_handlers: Sequence[AnyEventHandlerType] | None = None, commands: Sequence[BaseCommand] | None = None, hooks: AgentHooks | None = None, @@ -227,7 +227,7 @@ def __init__( self.tools = ToolManager() handlers = resolve_event_handlers(event_handlers) self.event_handler: MultiEventHandler[IndividualEventHandler] = MultiEventHandler(handlers) # ty: ignore[invalid-assignment] - self.hooks = hooks + self.hooks = hooks or AgentHooks() self._cancelled = False self._current_stream_task: asyncio.Task[Any] | None = None self._injection_manager = PromptInjectionManager() @@ -305,7 +305,7 @@ def __or__(self, other: MessageNode[Any, Any] | ProcessorCallback[Any]) -> TeamR return TeamRun([self, other]) - async def update_state(self, config_id: str, value_id: str) -> None: + async def update_state(self, config_id: str, value_id: str | bool) -> None: from agentpool.agents.modes import ConfigOptionChanged self.log.info("Config option changed", config_id=config_id, mode=value_id) @@ -434,7 +434,7 @@ async def run_iter( store_history=store_history, wait_for_connections=wait_for_connections, ) - yield response # pyright: ignore + yield response async def run_in_background( self, @@ -482,7 +482,7 @@ async def _continuous() -> ChatMessage[Any]: self.log.exception("Background run failed") await anyio.sleep(interval) self.log.debug("Continuous run completed", iterations=count) - return latest # type: ignore[return-value] + return latest # type: ignore[return-value] # ty:ignore[invalid-return-type] await self.stop() # Cancel any existing background task self._cancelled = False # Reset cancellation flag for new run @@ -1107,10 +1107,10 @@ async def get_modes(self) -> list[ModeCategory]: async def set_mode(self, mode: ModeInfo) -> None: ... @overload - async def set_mode(self, mode: str, category_id: ModeCategoryId | str) -> None: ... + async def set_mode(self, mode: str | bool, category_id: ModeCategoryId | str) -> None: ... async def set_mode( - self, mode: ModeInfo | str, category_id: ModeCategoryId | str | None = None + self, mode: ModeInfo | str | bool, category_id: ModeCategoryId | str | None = None ) -> None: """Set a mode within a category. @@ -1119,7 +1119,7 @@ async def set_mode( category_id: Category ID. Required if mode is a string, optional if ModeInfo. """ if isinstance(mode, ModeInfo): - mode_id = mode.id + mode_id = mode.value resolved_category = category_id or mode.category_id else: mode_id = mode @@ -1133,7 +1133,7 @@ async def set_mode( await self._set_mode(mode_id, resolved_category) @abstractmethod - async def _set_mode(self, mode_id: str, category_id: str) -> None: + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: """Agent-specific mode switching implementation.""" ... diff --git a/src/agentpool/agents/claude_code_agent/claude_code_agent.py b/src/agentpool/agents/claude_code_agent/claude_code_agent.py index 52f33292b..42a9b0940 100644 --- a/src/agentpool/agents/claude_code_agent/claude_code_agent.py +++ b/src/agentpool/agents/claude_code_agent/claude_code_agent.py @@ -1,57 +1,4 @@ -"""ClaudeCodeAgent - Native Claude Agent SDK integration. - -This module provides an agent implementation that wraps the Claude Agent SDK's -ClaudeSDKClient for native integration with agentpool. - -The ClaudeCodeAgent acts as a client to the Claude Code CLI, enabling: -- Bidirectional streaming communication -- Tool permission handling via callbacks -- Integration with agentpool's event system - -Tool Call Event Flow --------------------- -The SDK streams events in a specific order. Understanding this is critical for -avoiding race conditions with permission dialogs: - -1. **content_block_start** (StreamEvent) - - Contains tool_use_id, tool name - - We emit ToolCallStartEvent here (early, with empty args) - - ACP converter sends `tool_call` notification to client - -2. **content_block_delta** (StreamEvent, multiple) - - Contains input_json_delta with partial JSON args - - We emit PartDeltaEvent(ToolCallPartDelta) for streaming - - ACP converter accumulates args, doesn't send notifications - -3. **AssistantMessage** with ToolUseBlock - - Contains complete tool call info (id, name, full args) - - We do NOT emit events here (would race with permission) - - Just track file modifications silently - -4. **content_block_stop**, **message_delta**, **message_stop** (StreamEvent) - - Signal completion of the message - -5. **can_use_tool callback** (~100ms after message_stop) - - SDK calls our permission callback - - We send permission request to ACP client - - Client shows permission dialog to user - - IMPORTANT: No notifications should be sent while dialog is open! - -6. **Tool execution or denial** - - If allowed: tool runs, emits ToolCallCompleteEvent - - If denied: SDK receives denial, continues with next turn - -Example: - ```python - async with ClaudeCodeAgent( - name="claude_coder", - env="/path/to/project", - allowed_tools=["Read", "Write", "Bash"], - ) as agent: - async for event in agent.run_stream("Write a hello world program"): - print(event) - ``` -""" +"""ClaudeCodeAgent - Native Claude Agent SDK integration.""" from __future__ import annotations @@ -61,54 +8,32 @@ from decimal import Decimal from pathlib import Path import re -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, assert_never, cast import uuid import anyio from pydantic import TypeAdapter -from pydantic_ai import ( - FunctionToolResultEvent, - ModelRequest, - ModelResponse, - PartEndEvent, - TextPart, - ThinkingPart, - ToolCallPart, - ToolCallPartDelta, - ToolReturnPart, - UserPromptPart, -) -from pydantic_ai.usage import RequestUsage +from pydantic_ai import RunUsage from agentpool.agents.base_agent import BaseAgent from agentpool.agents.claude_code_agent.converters import ( confirmation_result_to_native, convert_mcp_servers_to_sdk_format, - convert_to_opencode_metadata, to_finish_reason, + to_mcp_server_status, to_prompt_input, - to_request_usage, to_run_usage, to_thinking_config, ) from agentpool.agents.claude_code_agent.slash_commands import create_claude_code_command from agentpool.agents.claude_code_agent.static_info import models_to_category -from agentpool.agents.events import ( - PartDeltaEvent, - PartStartEvent, - RunErrorEvent, - RunStartedEvent, - StreamCompleteEvent, - ToolCallCompleteEvent, - ToolCallStartEvent, -) -from agentpool.agents.events.infer_info import derive_rich_tool_info +from agentpool.agents.events import RunErrorEvent, RunStartedEvent, StreamCompleteEvent +from agentpool.agents.events.reconstructor import MessageReconstructor from agentpool.agents.exceptions import ( AgentNotInitializedError, UnknownCategoryError, UnknownModeError, ) -from agentpool.agents.tool_call_accumulator import ToolCallAccumulator from agentpool.common_types import MCPServerStatus from agentpool.log import get_logger from agentpool.messaging import ChatMessage @@ -129,18 +54,17 @@ PermissionMode, PermissionResult, ToolPermissionContext, - ToolUseBlock, ) from clawd_code_sdk.models import ( AskUserQuestionInput, - ElicitationRequest, - ElicitationResult, ReasoningEffort, + SDKControlElicitationRequest, StopReason, ToolInput, ) from evented_config import EventConfig from exxec import ExecutionEnvironment + from mcp.types import ElicitResult from pydantic_ai import UserContent from slashed import BaseCommand from tokonomics.model_discovery.model_info import ModelInfo @@ -153,12 +77,11 @@ from agentpool.delegation import AgentPool from agentpool.hooks import AgentHooks from agentpool.messaging import MessageHistory - from agentpool.models.claude_code_agents import ClaudeCodeAgentConfig, SettingSource + from agentpool.models.claude_code_agents import ClaudeCodeAgentConfig, SettingSource, ToolName from agentpool.resource_providers import ResourceProvider from agentpool.ui.base import InputProvider from agentpool_config.mcp_server import MCPServerConfig - logger = get_logger(__name__) ThinkingMode = Literal["off", "4k", "8k", "16k", "32k"] @@ -166,18 +89,19 @@ _MCP_TOOL_PATTERN = re.compile(r"^mcp__agentpool-(.+)-tools__(.+)$") """Pattern to detect CC-provided tool names ( mcp__agentpool-{agent_name}-tools__{tool_name} ).""" -ALLOWED_SLASH_COMMANDS = frozenset({ - # Skills that invoke the LLM and produce output over the wire - "init", - "debug", - "pr-comments", - "review", - "security-review", - "insights", - # Side-effect commands - "compact", +VALID_EFFORTS: set[str] = {"low", "medium", "high", "max"} + +# see https://github.com/zed-industries/claude-agent-acp/blob/main/src/acp-agent.ts for a list +UNSUPPORTED_COMMANDS = frozenset({ + # "cost", + "keybindings-help", + "login", + "logout", + "output-style:new", + "release-notes", + "todos", }) -"""Slash commands that produce useful output over the wire protocol.""" + THINKING_MODE_TOKENS: dict[ThinkingMode, int] = { "off": 0, @@ -222,8 +146,8 @@ def __init__( deps_type: type[TDeps] | None = None, description: str | None = None, display_name: str | None = None, - allowed_tools: list[str] | None = None, - disallowed_tools: list[str] | None = None, + allowed_tools: list[ToolName | str] | None = None, + disallowed_tools: list[ToolName | str] | None = None, system_prompt: str | Sequence[str | AnyPromptType] | None = None, include_builtin_system_prompt: bool = True, model: AnthropicMaxModelName | str | None = "opus", @@ -235,7 +159,7 @@ def __init__( mcp_servers: Sequence[MCPServerConfig] | None = None, env_vars: dict[str, str] | None = None, add_dir: list[str] | None = None, - builtin_tools: list[str] | None = None, + builtin_tools: list[ToolName | str] | None = None, fallback_model: AnthropicMaxModelName | str | None = None, setting_sources: list[SettingSource] | None = None, use_subscription: bool = False, @@ -332,7 +256,7 @@ def __init__( self._max_budget_usd = max_budget_usd self._max_thinking_tokens: int | Literal["adaptive"] | None = max_thinking_tokens self._effort: ReasoningEffort | None = reasoning_effort - self._permission_mode: PermissionMode | None = permission_mode + self._permission_mode: PermissionMode = permission_mode or "default" self._thinking_mode: ThinkingMode = "32k" self._external_mcp_servers = list(mcp_servers) if mcp_servers else [] self._env_vars = env_vars @@ -352,7 +276,7 @@ def __init__( # Claude storage provider is available via self.storage self._hook_manager = ClaudeCodeHookManager( agent_name=self.name, - agent_hooks=hooks, + agent_hooks=self.hooks, injection_manager=self._injection_manager, set_mode=self._set_mode, env=self.env, @@ -414,7 +338,7 @@ def from_config( event_handlers=merged_handlers or None, input_provider=input_provider, agent_pool=agent_pool, - output_type=resolved_output_type, # type: ignore[arg-type] + output_type=resolved_output_type, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] hooks=config.hooks.get_agent_hooks() if config.hooks else None, ) @@ -456,42 +380,20 @@ def model_name(self) -> str | None: return self._model async def get_mcp_server_info(self) -> dict[str, MCPServerStatus]: - """Get information about configured MCP servers. - - Returns a dict mapping server names to their status info. This is used - by the OpenCode /mcp endpoint to display MCP servers in the sidebar. - - If a client is connected, queries live status from Claude Code. - Otherwise falls back to reporting from config. - - Returns: - Dict mapping server name to MCPServerStatus dataclass - """ + """Get information about configured MCP servers.""" result: dict[str, MCPServerStatus] = {} # Try live status from connected client if self._client: try: await self.ensure_initialized() - live_status = await self._client.get_mcp_status() + mcp_servers = await self._client.get_mcp_status() except Exception: # noqa: BLE001 pass else: - for server in live_status.mcp_servers: - name = server.name - server_info = server.server_info - assert server_info # TODO: remove assert - result[name] = MCPServerStatus( - name=name, - status=server.status, - server_type=server.config.get("type", "unknown"), - server_name=server_info.name, - server_version=server_info.version, - ) - return result + return {s.name: to_mcp_server_status(s) for s in mcp_servers} # Fallback: report from config for name, config in self._mcp_servers.items(): - server_type = config.get("type", "unknown") - result[name] = MCPServerStatus(name=name, status="connected", server_type=server_type) + result[name] = MCPServerStatus(name=name, status="connected", server_type=config.type) return result def _get_client( @@ -507,13 +409,8 @@ def _get_client( fork_session: Whether to fork the session """ from clawd_code_sdk import ClaudeAgentOptions, ClaudeSDKClient - from clawd_code_sdk.models.options import NewSession, ResumeSession + from clawd_code_sdk.models import NewSession, ResumeSession - # Determine permission and elicitation callbacks - bypass = self._permission_mode == "bypassPermissions" - can_use_tool = self._can_use_tool if not bypass else None - on_user_question = self._on_user_question - on_elicitation = self._on_elicitation # Check builtin_tools for special tools that need extra handling builtin_tools = self._builtin_tools or [] # Build environment variables @@ -524,14 +421,11 @@ def _get_client( env["ENABLE_LSP_TOOL"] = "1" if self._use_subscription: # Force subscription usage by clearing API key env["ANTHROPIC_API_KEY"] = "" - - # Build session config - session: NewSession | ResumeSession - if self._sdk_session_id: - session = ResumeSession(session_id=self._sdk_session_id, fork=fork_session) - else: - session = NewSession() - + session = ( + ResumeSession(session_id=self._sdk_session_id, fork=fork_session) + if self._sdk_session_id + else NewSession() + ) opts = ClaudeAgentOptions( cwd=self.env.cwd, allowed_tools=self._allowed_tools or [], @@ -549,9 +443,9 @@ def _get_client( add_dirs=self._add_dir or [], tools=self._builtin_tools, fallback_model=self._fallback_model, - can_use_tool=can_use_tool, - on_user_question=on_user_question, - on_elicitation=on_elicitation, + on_permission=self._on_permission, + on_user_question=self._on_user_question, + on_elicitation=self._on_elicitation, output_schema=self._output_type if self._output_type is not str else None, mcp_servers=self._mcp_servers or {}, hooks=self._hook_manager.build_hooks(), @@ -559,132 +453,90 @@ def _get_client( chrome="Chrome" in builtin_tools, session=session, stderr=lambda line: logger.debug("claude_cli_stderr", output=line), + allow_dangerously_skip_permissions=True, ) return ClaudeSDKClient(opts) - async def _can_use_tool( + async def _on_permission( self, tool_name: str, input_data: ToolInput | dict[str, Any], context: ToolPermissionContext, ) -> PermissionResult: - """Handle tool permission requests. - - Args: - tool_name: Name of the tool being called (e.g., "Bash", "Write") - input_data: Tool input arguments - context: Permission context with suggestions - - Returns: - PermissionResult indicating allow or deny - """ + """Handle tool permission requests.""" from clawd_code_sdk import PermissionResultAllow, PermissionResultDeny input_dict = cast(dict[str, Any], input_data) - # Auto-grant if bypassPermissions mode is active - match self._permission_mode: - case "bypassPermissions": - return PermissionResultAllow() - case "plan": - return PermissionResultDeny(message="Plan mode active - tool execution disabled") - case "acceptEdits": - actual_tool_name = _strip_mcp_prefix(tool_name) - # Auto-allow file editing tools - if actual_tool_name.lower() in ("edit", "write", "edit_file", "write_file"): - return PermissionResultAllow() - - # For "default" mode and non-edit tools in "acceptEdits" mode: - # Ask for confirmation via input provider - tool_call_id = context.tool_use_id + tc_id = context.tool_use_id display_name = _strip_mcp_prefix(tool_name) - self.log.debug("Permission request", tool_name=display_name, tool_call_id=tool_call_id) + self.log.debug("Permission request", tool_name=display_name, tool_call_id=tc_id) if self._tool_bridge._current_context is None: raise RuntimeError("Permission callback invoked outside of an active run") ctx = replace( self._tool_bridge._current_context, - tool_call_id=tool_call_id, + tool_call_id=tc_id, tool_input=input_dict, tool_name=display_name, ) input_provider = ctx.get_input_provider() - result = await input_provider.get_tool_confirmation( - context=ctx, - tool_description=f"Claude Code tool: {tool_name}", - ) - return confirmation_result_to_native(result) + # Auto-grant if bypassPermissions mode is active + match self._permission_mode: + case "bypassPermissions": + return PermissionResultAllow() + case "plan": + return PermissionResultDeny(message="Plan mode active - tool execution disabled") + case "acceptEdits": + # Auto-allow file editing tools + if display_name.lower() in ("edit", "write", "edit_file", "write_file"): + return PermissionResultAllow() + result = await input_provider.get_tool_confirmation(context=ctx) + return confirmation_result_to_native(result) + case "default" | "plan" | "delegate" | "dontAsk" | "auto": + result = await input_provider.get_tool_confirmation(context=ctx) + return confirmation_result_to_native(result) + case _ as unreachable: + assert_never(unreachable) async def _on_user_question( self, input_data: AskUserQuestionInput, context: ToolPermissionContext, ) -> PermissionResult: - """Handle AskUserQuestion elicitation requests. - - Called when Claude asks the user a clarifying question. - - Args: - input_data: Input containing 'questions' array - context: Permission context with tool_use_id - - Returns: - PermissionResult with answers or denial - """ + """Handle AskUserQuestion elicitation requests.""" from agentpool.agents.claude_code_agent.elicitation import handle_clarifying_questions - if self._tool_bridge._current_context is None: + ctx = self._tool_bridge._current_context + if ctx is None: raise RuntimeError("User question callback invoked outside of an active run") return await handle_clarifying_questions( - self._tool_bridge._current_context, - input_data, - context, + agent_ctx=ctx, + input_data=input_data, + context=context, ) - async def _on_elicitation( - self, - request: ElicitationRequest, - ) -> ElicitationResult: - """Handle MCP elicitation requests. - - Converts from Claude SDK's ElicitationRequest to MCP's ElicitRequestParams, - delegates to the input provider, and converts back. - - Args: - request: Elicitation request from an MCP server - - Returns: - ElicitationResult with user's response - """ - from clawd_code_sdk.models import ElicitationResult - from mcp.types import ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult + async def _on_elicitation(self, request: SDKControlElicitationRequest) -> ElicitResult: + """Handle MCP elicitation requests.""" + from mcp.types import ElicitResult, ErrorData if self._tool_bridge._current_context is None: raise RuntimeError("Elicitation callback invoked outside of an active run") input_provider = self._tool_bridge._current_context.get_input_provider() - # Convert SDK ElicitationRequest to MCP ElicitRequestParams - mcp_params: ElicitRequestURLParams | ElicitRequestFormParams - if request.mode == "url": - mcp_params = ElicitRequestURLParams( - message=request.message, - url=request.url or "", - elicitationId=request.elicitation_id or "", - ) - else: - mcp_params = ElicitRequestFormParams( - message=request.message, - requestedSchema=request.requested_schema or {}, - ) - - result = await input_provider.get_elicitation(params=mcp_params) + match request.mode: + case "url" | "form": + params = request.to_mcp() + case None: + raise ValueError("Elicitation request mode must be 'url' or 'form'") + case _ as unreachable: + assert_never(unreachable) - # Convert MCP ElicitResult back to SDK ElicitationResult - if isinstance(result, ElicitResult): - return ElicitationResult( - action=result.action, - content=dict(result.content) if result.content else None, - ) - # ErrorData case - treat as decline - return ElicitationResult(action="decline") + match await input_provider.get_elicitation(params=params): + case ElicitResult() as result: + return result + case ErrorData(): + return ElicitResult(action="decline") + case _ as unreachable_: + assert_never(unreachable_) # ty:ignore[type-assertion-failure] async def __aenter__(self) -> Self: """Connect to Claude Code with deferred client connection.""" @@ -811,7 +663,7 @@ async def _populate_commands(self) -> None: commands = [ create_claude_code_command(cmd_info) for cmd_info in server_info.commands - if cmd_info.name and cmd_info.name in ALLOWED_SLASH_COMMANDS + if cmd_info.name and cmd_info.name not in UNSUPPORTED_COMMANDS ] for command in commands: self._command_store.register_command(command, replace=True) @@ -832,332 +684,78 @@ async def _stream_events( # noqa: PLR0915 wait_for_connections: bool | None = None, store_history: bool = True, ) -> AsyncIterator[RichAgentStreamEvent[TResult]]: - from anthropic.types import ( - InputJSONDelta, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - TextBlock as AnthTextBlock, - TextDelta, - ThinkingBlock as AnthThinkingBlock, - ThinkingDelta, - ToolUseBlock as AnthToolUseBlock, - ) - from clawd_code_sdk import ( - AssistantMessage, - Message, - ResultMessage, - ResultSuccessMessage, - TextBlock, - ThinkingBlock, - ToolResultBlock, - ToolUseBlock, - UserMessage, - ) - from clawd_code_sdk.models import ( - CompactBoundarySystemMessage, - StatusSystemMessage, - StreamEvent, - ) + from clawd_code_sdk import AssistantMessage, ResultSuccessMessage, UserMessage - await self.ensure_initialized() - # Initialize session_id on first run and log to storage - # Use passed session_id if provided (e.g., from chained agents) - # TODO: decide whether we should store CC sessions ourselves - # For Claude Code, session_id comes from the SDK's init message: - # if hasattr(message, 'subtype') and message.subtype == 'init': - # session_id = message.data.get('session_id') - # The SDK manages its own session persistence. To resume, pass: - # ClaudeAgentOptions(session=ResumeSession(session_id=session_id)) - # Conversation ID initialization handled by BaseAgent + from agentpool.agents.claude_code_agent.stream_adapter import ClaudeCodeStreamedResponse + await self.ensure_initialized() # Resolve input provider: explicit parameter overrides agent default effective_input_provider = input_provider or self._input_provider run_context = self.get_context(data=deps, input_provider=effective_input_provider) if not self._client: raise AgentNotInitializedError - # Get pending parts from conversation (staged content) - # Combine pending parts with new prompts, then join into single string for Claude SDK run_id = str(uuid.uuid4()) assert self.session_id is not None # Initialized by BaseAgent.run_stream() yield RunStartedEvent(session_id=self.session_id, run_id=run_id, agent_name=self.name) - request = ModelRequest(parts=[UserPromptPart(content=prompts)]) - model_messages: list[ModelResponse | ModelRequest] = [request] - current_response_parts: list[TextPart | ThinkingPart | ToolCallPart] = [] - pending_tool_calls: dict[str, ToolUseBlock] = {} - # Track tool calls that already had ToolCallStartEvent emitted (via StreamEvent) - emitted_tool_starts: set[str] = set() - tool_accumulator = ToolCallAccumulator() - resolved_model: str | None = None + # Handle ephemeral execution (fork session if store_history=False) fork_client = None client = self._client - result_message: ResultMessage | None = None - if not store_history and self._sdk_session_id: # Create fork client that shares parent's context but has separate session ID # See: src/agentpool/agents/claude_code_agent/FORKING.md - # Build options using same method as main client fork_client = self._get_client(fork_session=True) await fork_client.connect() client = fork_client - # Set run context on tool bridge (ContextVar doesn't work - separate task) + reconstructor = MessageReconstructor(initial_prompts=prompts) + claude_prompts = [*to_prompt_input(prompts)] try: - claude_prompts = [*to_prompt_input(prompts)] await client.query(*claude_prompts) # Capture SDK session ID from init message stream = client.receive_response() first_msg = await anext(stream) - assert not isinstance(first_msg, AssistantMessage), ( + assert not isinstance(first_msg, AssistantMessage | UserMessage), ( f"invalid message type {type(first_msg)}" ) self._sdk_session_id = first_msg.session_id # Persist SDK session ID to storage for cross-referencing if self.storage and self.session_id: await self.storage.update_sdk_session_id(self.session_id, self._sdk_session_id) + adapter = ClaudeCodeStreamedResponse( + stream=stream, + tool_metadata=self._tool_bridge.tool_metadata, + agent_name=self.name, + session_id=self.session_id, + ) async with ( self._tool_bridge.set_run_context(run_context, prompt=prompts), - merge_queue_into_iterator(stream, self._event_queue) as merged_events, # ty: ignore[invalid-argument-type] + merge_queue_into_iterator(adapter, self._event_queue) as merged_events, # ty: ignore[invalid-argument-type] ): - async for event_or_message in merged_events: - if not isinstance(event_or_message, Message): - yield event_or_message - continue - message = event_or_message - match message: - case AssistantMessage(model=model, content=msg_content): - # Track resolved model from provider response - if model: - resolved_model = model - # Check for usage limit error - for block in msg_content: - match block: - case TextBlock(text=text): - current_response_parts.append(TextPart(content=text)) - case ThinkingBlock(thinking=text): - current_response_parts.append(ThinkingPart(content=text)) - case ToolUseBlock(id=tc_id, name=name, input=input_data): - pending_tool_calls[tc_id] = block - display_name = _strip_mcp_prefix(name) - tool_call_part = ToolCallPart( - tool_name=display_name, - args=cast(dict[str, Any], input_data), - tool_call_id=tc_id, - ) - current_response_parts.append(tool_call_part) - # Emit FunctionToolCallEvent (triggers UI notification) - # fn_tool_event = FunctionToolCallEvent(part=tool_call_part) - # await event_handlers(None, fn_tool_event) - # yield fn_tool_event - # Only emit ToolCallStartEvent if not already emitted - # via streaming (emits early with partial info) - if tc_id not in emitted_tool_starts: - rich_info = derive_rich_tool_info(name, input_data) - tool_start_event = ToolCallStartEvent( - tool_call_id=tc_id, - tool_name=display_name, - title=rich_info.title, - kind=rich_info.kind, - locations=rich_info.locations, - content=rich_info.content, - raw_input=cast(dict[str, Any], input_data), - ) - yield tool_start_event - # Clean up from accumulator (always, both branches) - tool_accumulator.complete(tc_id) - case ToolResultBlock(): - pass # ToolResult Blocks only appear in UserMessages - # Process user messages - may contain tool results - case UserMessage(content=list() as user_blocks): # TODO: handle str? - # Extract tool_use_result from UserMessage for metadata conversion - for user_block in user_blocks: - if isinstance(user_block, ToolResultBlock): - tc_id = user_block.tool_use_id - result_content = user_block.get_parsed_content() - # Flush response parts - if current_response_parts: - model_response = ModelResponse(parts=current_response_parts) - model_messages.append(model_response) - current_response_parts = [] - - # Get tool name from pending calls - tool_use = pending_tool_calls.pop(tc_id) - # Create ToolReturnPart for the result - return_part = ToolReturnPart( - tool_name=_strip_mcp_prefix(tool_use.name), - content=result_content, - tool_call_id=tc_id, - ) - # Emit FunctionToolResultEvent (for session.py to complete UI) - yield FunctionToolResultEvent(result=return_part) - # Build metadata: prefer existing tool_metadata, - # then convert SDK result - tool_input = ( - cast(dict[str, Any], tool_use.input) if tool_use else {} - ) - metadata: dict[str, Any] | None = ( - self._tool_bridge.tool_metadata.get(tc_id) - ) - if not metadata and isinstance(message.tool_use_result, list): - result = ( - message.tool_use_result[0] - if message.tool_use_result - else {} - ) - - # Convert Claude Code SDK's tool_use_result to OpenCode fmt - metadata = convert_to_opencode_metadata( - tool_use.name, - result, # pyright: ignore[reportArgumentType] - tool_input, - ) # type: ignore[assignment] - - # Also emit ToolCallCompleteEvent for consumers that expect it - yield ToolCallCompleteEvent( - tool_name=_strip_mcp_prefix(tool_use.name), - tool_call_id=tc_id, - tool_input=tool_input, - tool_result=result_content, - agent_name=self.name, - message_id="", - metadata=metadata, - ) - # Add tool return as ModelRequest - model_messages.append(ModelRequest(parts=[return_part])) - - # Handle StreamEvent for real-time streaming - case StreamEvent( - event=RawContentBlockStartEvent( - index=index, content_block=AnthTextBlock() - ) - ): - yield PartStartEvent.text(index=index, content="") - - case StreamEvent( - event=RawContentBlockStartEvent( - index=index, content_block=AnthThinkingBlock() - ) - ): - yield PartStartEvent.thinking(index=index, content="") - - case StreamEvent( - event=RawContentBlockStartEvent( - content_block=AnthToolUseBlock(id=tc_id, name=raw_tool_name) - ) - ): - # Emit ToolCallStartEvent early (args still streaming) - tool_name = _strip_mcp_prefix(raw_tool_name) - tool_accumulator.start(tc_id, tool_name) - # Derive rich info with empty args for now - rich_info = derive_rich_tool_info(raw_tool_name, {}) - emitted_tool_starts.add(tc_id) - yield ToolCallStartEvent( - tool_call_id=tc_id, - tool_name=tool_name, - title=rich_info.title, - kind=rich_info.kind, - locations=[], # No locations yet, args not complete - content=rich_info.content, - raw_input={}, # Empty, will be filled when complete - ) - - # content_block_delta events - case StreamEvent( - event=RawContentBlockDeltaEvent(index=index, delta=TextDelta(text=text)) - ) if text: - yield PartDeltaEvent.text(index=index, content=text) - case StreamEvent( - event=RawContentBlockDeltaEvent( - index=index, delta=ThinkingDelta(thinking=thinking) - ) - ) if thinking: - yield PartDeltaEvent.thinking(index=index, content=thinking) - case StreamEvent( - event=RawContentBlockDeltaEvent( - index=index, delta=InputJSONDelta(partial_json=partial_json) - ) - ) if partial_json: - # Accumulate tool argument JSON fragments - # Find which tool call this belongs to by index - for tc_id in tool_accumulator._calls: - tool_accumulator.add_args(tc_id, partial_json) - tool_delta = ToolCallPartDelta( - args_delta=partial_json, - tool_call_id=tc_id, - ) - yield PartDeltaEvent(index=index, delta=tool_delta) - break # Only one tool call streams at a time - - # content_block_stop events - case StreamEvent(event=RawContentBlockStopEvent(index=index)): - # Emit with empty part - content was accumulated via deltas - yield PartEndEvent(index=index, part=TextPart(content="")) - - case StatusSystemMessage(status="compacting"): - from agentpool.agents.events import CompactionEvent - - yield CompactionEvent( - session_id=self.session_id or "unknown", - trigger="auto", - phase="starting", - ) - continue - - case CompactBoundarySystemMessage(compact_metadata=compact_metadata): - from agentpool.agents.events import CompactionEvent - - yield CompactionEvent( - session_id=self.session_id or "unknown", - trigger=compact_metadata["trigger"], - phase="completed", - pre_tokens=compact_metadata["pre_tokens"], - ) - continue - - case StreamEvent(): - # Ignore other StreamEvent types (message_start, etc.) - # Skip further processing - don't duplicate - continue - - # All other message types (ResultMessage, InitSystemMessage, etc.) - # fall through to post-match processing below - - # Check for result (end of response) and capture usage info - if isinstance(message, ResultMessage): - result_message = message - break - - # Note: We do NOT return early on cancellation here. - # The SDK docs warn against using break/return to exit receive_response() - # early as it can cause asyncio cleanup issues. Instead, we let the - # interrupt() call cause the SDK to send a ResultMessage that will - # naturally terminate the stream via the isinstance(message, ResultMessage) - # check above. The _cancelled flag is checked in process_prompt() to - # return the correct stop reason. + async for event in merged_events: + reconstructor.observe(event) # ty:ignore[invalid-argument-type] + yield event # ty:ignore[invalid-yield] except asyncio.CancelledError: self.log.info("Stream cancelled via CancelledError") - # Emit partial response on cancellation - # Build metadata with SDK session ID msg_metadata: SimpleJsonType = {} if self._sdk_session_id: msg_metadata["sdk_session_id"] = self._sdk_session_id - content = "".join(i.content for i in current_response_parts if isinstance(i, TextPart)) + reconstructor.flush() + resolved = adapter.model_name or self.model_name # pyright: ignore[reportPossiblyUnboundVariable] response_msg = ChatMessage[TResult]( - content=content, # type: ignore[arg-type] + content=reconstructor.text_content, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] role="assistant", name=self.name, message_id=message_id or str(uuid.uuid4()), session_id=self.session_id, parent_id=user_msg.message_id, - model_name=resolved_model or self.model_name, - messages=model_messages, + model_name=resolved, + messages=reconstructor.model_messages, finish_reason="stop", metadata=msg_metadata, ) yield StreamCompleteEvent(message=response_msg) - # Post-processing handled by base class return except Exception as e: @@ -1165,30 +763,27 @@ async def _stream_events( # noqa: PLR0915 raise finally: - # Disconnect fork client if we created one if fork_client: try: await fork_client.disconnect() except Exception as e: # noqa: BLE001 self.log.warning("Error disconnecting fork client", error=e) - # Flush any remaining response parts - if current_response_parts: - model_messages.append(ModelResponse(parts=current_response_parts)) + reconstructor.flush() # Determine final content - use structured output if available - content = "".join(i.content for i in current_response_parts if isinstance(i, TextPart)) + result_message = adapter._result_message + content = reconstructor.text_content final_content: TResult if ( self._output_type is not str and isinstance(result_message, ResultSuccessMessage) and result_message.structured_output ): - # Validate structured output against expected type - adapter = TypeAdapter(self._output_type) - final_content = adapter.validate_python(result_message.structured_output) + _adapter = TypeAdapter(self._output_type) + final_content = _adapter.validate_python(result_message.structured_output) else: - final_content = content # type: ignore[assignment] + final_content = content # type: ignore[assignment] # ty:ignore[invalid-assignment] # Build cost_info and usage from client per-query tracking. # result_message.total_cost_usd is cumulative across the session, @@ -1196,13 +791,12 @@ async def _stream_events( # noqa: PLR0915 # result_message.usage is last-API-call-only; client.query_usage # accumulates all API calls in the turn. cost_info: TokenCost | None = None - request_usage: RequestUsage | None = None + run_usage: RunUsage = RunUsage() stop_reason: StopReason | None = "end_turn" if result_message: run_usage = to_run_usage(client.query_usage) total_cost = Decimal(str(client.query_cost)) - cost_info = TokenCost(token_usage=run_usage, total_cost=total_cost) - request_usage = to_request_usage(client.query_usage) + cost_info = TokenCost(total_cost=total_cost) stop_reason = result_message.stop_reason # Build metadata with SDK session ID msg_metadata = {} @@ -1218,10 +812,10 @@ async def _stream_events( # noqa: PLR0915 message_id=message_id or str(uuid.uuid4()), session_id=self.session_id, parent_id=user_msg.message_id, - model_name=resolved_model or self.model_name, - messages=model_messages, + model_name=adapter.model_name or self.model_name, + messages=reconstructor.model_messages, cost_info=cost_info, - usage=request_usage or RequestUsage(), + usage=run_usage, response_time=result_message.duration_ms / 1000 if result_message else None, finish_reason=finish_reason, metadata=msg_metadata, @@ -1243,6 +837,17 @@ async def set_model(self, model: AnthropicMaxModelName | str) -> None: """Set the model for future requests.""" await self._set_mode(model, "model") + async def set_effort(self, effort: ReasoningEffort) -> None: + """Set reasoning effort level. + + This requires a session reconnect since effort is a CLI startup flag. + The current session is preserved via session resumption. + + Args: + effort: Reasoning effort level ("low", "medium", "high", "max") + """ + await self._set_mode(effort, "effort") + async def set_permission_mode(self, mode: PermissionMode) -> None: """Set permission mode.""" await self._set_mode(mode, "mode") @@ -1256,12 +861,17 @@ async def get_available_models(self) -> list[ModelInfo]: async def get_modes(self) -> list[ModeCategory]: """Get available mode categories for Claude Code agent. - Claude Code exposes permission modes and model selection. + Claude Code exposes permission modes, model selection, thinking level, + and reasoning effort. Returns: - List of ModeCategory for permissions and models + List of ModeCategory for permissions, models, thinking, and effort """ - from agentpool.agents.claude_code_agent.static_info import MODES, THINKING_MODES + from agentpool.agents.claude_code_agent.static_info import ( + EFFORT_MODES, + MODES, + THINKING_MODES, + ) from agentpool.agents.modes import ModeCategory categories = [ @@ -1286,12 +896,22 @@ async def get_modes(self) -> list[ModeCategory]: category="thought_level", ) ) + # Reasoning effort selection + categories.append( + ModeCategory( + id="effort", + name="Reasoning Effort", + available_modes=EFFORT_MODES, + current_mode_id=self._effort or "high", + category="other", + ) + ) return categories - async def _set_mode(self, mode_id: str, category_id: str) -> None: - """Handle permissions, model, and thinking_level mode switching.""" - from clawd_code_sdk import PermissionMode + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: + """Handle permissions, model, thinking_level, and effort mode switching.""" + from clawd_code_sdk.models import ReasoningEffort from agentpool.agents.claude_code_agent.static_info import VALID_MODES @@ -1300,7 +920,7 @@ async def _set_mode(self, mode_id: str, category_id: str) -> None: # Map mode_id to PermissionMode if mode_id not in VALID_MODES: raise UnknownModeError(mode_id, list(VALID_MODES)) - self._permission_mode = cast(PermissionMode, mode_id) + self._permission_mode = mode_id # ty:ignore[invalid-assignment] if self._client: # Update SDK client if initialized await self.ensure_initialized() await self._client.set_permission_mode(self._permission_mode) @@ -1311,20 +931,30 @@ async def _set_mode(self, mode_id: str, category_id: str) -> None: if mode_id not in valid_ids: raise UnknownModeError(mode_id, list(valid_ids)) # Set the model directly + assert isinstance(mode_id, str) self._model = mode_id if self._client: await self.ensure_initialized() + assert isinstance(mode_id, str) await self._client.set_model(mode_id) case "thought_level": # Validate thinking mode if mode_id not in THINKING_MODE_TOKENS: raise UnknownModeError(mode_id, list(THINKING_MODE_TOKENS.keys())) - self._thinking_mode = mode_id # type: ignore[assignment] + self._thinking_mode = mode_id # ty:ignore[invalid-assignment] # Set thinking tokens via SDK if self._client: await self.ensure_initialized() tokens = THINKING_MODE_TOKENS[self._thinking_mode] await self._client.set_max_thinking_tokens(tokens) + case "effort": + # Validate effort level + if mode_id not in VALID_EFFORTS: + raise UnknownModeError(mode_id, list(VALID_EFFORTS)) + self._effort = cast(ReasoningEffort, mode_id) + # Effort is a CLI startup flag only - requires session reconnect + if self._client: + await self.reconnect(resume_session=True) case _: raise UnknownCategoryError(category_id) await self.update_state(config_id=category_id, value_id=mode_id) @@ -1339,10 +969,9 @@ async def list_sessions( storage = self.storage if not storage: return [] - session_ids = await storage.list_session_ids(agent_name=self.name) result: list[SessionData] = [] default_cwd = str(self.env.cwd or Path.cwd()) - for session_id in session_ids: + for session_id in await storage.list_session_ids(agent_name=self.name): if session_data := await storage.load_session(session_id): if not session_data.cwd: session_data = session_data.model_copy(update={"cwd": default_cwd}) @@ -1381,8 +1010,7 @@ async def load_session(self, session_id: str) -> SessionData | None: error_msg = "Failed to reconnect with loaded session, continuing with local history" self.log.exception(error_msg, session_id=session_id) # Build SessionData from storage metadata - session_data = await storage.load_session(session_id) - if session_data: + if session_data := await storage.load_session(session_id): return session_data # Fallback: build from messages last_active = messages[-1].timestamp or get_now() @@ -1401,11 +1029,8 @@ async def load_session(self, session_id: str) -> SessionData | None: if __name__ == "__main__": - import os import time - os.environ["ANTHROPIC_API_KEY"] = "" - async def main() -> None: """Demo: Basic call to Claude Code.""" async with ClaudeCodeAgent(name="demo", event_handlers=["detailed"]) as agent: diff --git a/src/agentpool/agents/claude_code_agent/converters.py b/src/agentpool/agents/claude_code_agent/converters.py index 060c69b96..614118eb4 100644 --- a/src/agentpool/agents/claude_code_agent/converters.py +++ b/src/agentpool/agents/claude_code_agent/converters.py @@ -10,20 +10,20 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, assert_never, cast -from clawd_code_sdk import ( - McpServerConfig, - UserDocumentPrompt, - UserDocumentURLPrompt, - UserImageURLPrompt, -) from clawd_code_sdk.models import ( BashInput, BashOutput, EditOutput, + McpHttpServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, ReadOutput, TodoWriteOutput, + UserDocumentPrompt, + UserDocumentURLPrompt, UserFilePrompt, UserImagePrompt, + UserImageURLPrompt, UserTextPrompt, WriteOutput, ) @@ -35,12 +35,15 @@ ImageUrl, RequestUsage, RunUsage, + TextContent, UploadedFile, VideoUrl, ) +from pydantic_ai.models.anthropic import _FINISH_REASON_MAP as FINISH_REASON_MAP +from agentpool.common_types import MCPServerStatus from agentpool.utils.diffs import compute_unified_diff -from agentpool_server.opencode_server.models.tool_metadata import ( +from opencode_sdk.models.tool_metadata import ( BashMetadata, EditMetadata, FileDiff, @@ -54,12 +57,22 @@ if TYPE_CHECKING: from collections.abc import Iterator, Sequence - from clawd_code_sdk import PermissionResult, ThinkingConfig from clawd_code_sdk.models import ( + HookContext, HookEvent, + HookJSONOutput, + HookMatcher, + McpServerConfig, + McpServerStatusEntry, + PermissionResult, + PostToolUseHookInput, + PreToolUseHookInput, StopReason, StructuredPatchHunk, + SyncHookJSONOutput, + ThinkingConfig, ToolInput, + ToolUseResult, Usage, UserPrompt, ) @@ -69,7 +82,7 @@ from agentpool.agents.context import ConfirmationResult from agentpool.hooks import AgentHooks from agentpool_config.mcp_server import MCPServerConfig as NativeMCPServerConfig - from agentpool_server.opencode_server.models.tool_metadata import ToolMetadata + from opencode_sdk.models.tool_metadata import ToolMetadata def to_thinking_config( @@ -88,6 +101,16 @@ def to_thinking_config( return None +def to_mcp_server_status(server: McpServerStatusEntry) -> MCPServerStatus: + return MCPServerStatus( + name=server.name, + status=server.status, + server_type=server.config.type if server.config else "unknown", + server_name=server.server_info.name if server.server_info else None, + server_version=server.server_info.version if server.server_info else None, + ) + + def to_prompt_input(content: Sequence[UserContent]) -> Iterator[UserPrompt]: for item in content: match item: @@ -107,7 +130,7 @@ def to_prompt_input(content: Sequence[UserContent]) -> Iterator[UserPrompt]: yield UserFilePrompt(file_id=file_id) case UploadedFile(file_id=file_id, provider_name=provider_name): raise ValueError(f"Unsupported UploadedFile: {provider_name=} {file_id=}") - case str() as text: + case str(text) | TextContent(content=text): yield UserTextPrompt(text=text) case BinaryContent(): pass # video/audio not handled yet @@ -152,18 +175,7 @@ def confirmation_result_to_native(result: ConfirmationResult) -> PermissionResul def to_finish_reason(reason: StopReason) -> FinishReason: - - match reason: - case "end_turn": - return "stop" - case "max_tokens" | "model_context_window_exceeded": - return "length" - case "stop_sequence" | "pause_turn" | "refusal": - return "stop" - case "tool_use": - return "tool_call" - case _ as unreachable: - raise assert_never(unreachable) + return FINISH_REASON_MAP[reason] def convert_mcp_servers_to_sdk_format( @@ -199,31 +211,31 @@ def convert_mcp_servers_to_sdk_format( assert_never(unreachable) # Build SDK-compatible config - config: dict[str, Any] + config: McpServerConfig match server: case StdioMCPServerConfig(command=command, args=args): - config = {"type": "stdio", "command": command, "args": args} + config = McpStdioServerConfig(command=command, args=args) if server.env: - config["env"] = server.get_env_vars() + config.env = server.get_env_vars() case SSEMCPServerConfig(url=url): - config = {"type": "sse", "url": str(url)} + config = McpSSEServerConfig(url=str(url)) if server.headers: - config["headers"] = server.headers + config.headers = server.headers case StreamableHTTPMCPServerConfig(url=url): - config = {"type": "http", "url": str(url)} + config = McpHttpServerConfig(url=str(url)) if server.headers: - config["headers"] = server.headers + config.headers = server.headers case _ as unreachable: assert_never(unreachable) - result[name] = cast(McpServerConfig, config) + result[name] = config return result def convert_to_opencode_metadata( # noqa: PLR0911 tool_name: str, - tool_use_result: dict[str, Any] | ToolInput | str | None, + tool_use_result: dict[str, Any] | ToolUseResult | str | None, tool_input: ToolInput | dict[str, Any] | None = None, ) -> ToolMetadata | None: """Convert Claude Code SDK tool_use_result to OpenCode metadata format.""" @@ -259,13 +271,11 @@ def _convert_edit_result(result: EditOutput) -> EditMetadata: """Convert Edit tool result to OpenCode metadata.""" file_path = result["filePath"] original_file = result["originalFile"] - old_string = result["oldString"] - new_string = result["newString"] structured_patch = result["structuredPatch"] # Compute the "after" content by applying the edit after_content = original_file - if original_file is not None and old_string and new_string: - after_content = original_file.replace(old_string, new_string, 1) + if original_file is not None and (old := result["oldString"]) and (new := result["newString"]): + after_content = original_file.replace(old, new, 1) # Build unified diff from structuredPatch or compute it diff = _build_unified_diff(file_path, original_file, after_content, structured_patch) @@ -297,12 +307,7 @@ def _convert_read_result(result: ReadOutput) -> ReadMetadata: def _convert_bash_result(result: BashOutput, tool_input: BashInput) -> BashMetadata: """Convert Bash tool result to OpenCode metadata.""" - stdout = result["stdout"] - stderr = result["stderr"] - # Combine stdout and stderr - output = stdout - if stderr: - output = f"{stdout}\n{stderr}" if stdout else stderr + output = f"{result['stdout']}\n{result['stderr']}".strip() # Get description from tool input (Claude Code uses "description" field) description = tool_input.get("description") or tool_input["command"] # Note: Claude Code SDK doesn't provide exit code in the success result structure, @@ -332,7 +337,7 @@ def _convert_todowrite_result(result: TodoWriteOutput) -> TodoMetadata | None: _MEDIUM_PRIORITY_THRESHOLD = 0.67 -def _infer_priority(content: str, index: int, total: int) -> str: +def _infer_priority(content: str, index: int, total: int) -> Literal["low", "medium", "high"]: """Infer priority from content keywords or position.""" content_lower = content.lower() @@ -427,7 +432,7 @@ def build_sdk_hooks_from_agent_hooks( hooks: AgentHooks, agent_name: str, env: ExecutionEnvironment | None = None, -) -> dict[HookEvent, list[Any]]: +) -> dict[HookEvent, list[HookMatcher]]: """Convert AgentHooks to Claude SDK hooks format. Args: @@ -440,26 +445,22 @@ def build_sdk_hooks_from_agent_hooks( """ from clawd_code_sdk.models import HookMatcher - result: dict[HookEvent, list[Any]] = {} + result: dict[HookEvent, list[HookMatcher]] = {} if hooks.pre_tool_use: async def on_pre_tool_use( - input_data: Any, + input_data: PreToolUseHookInput, tool_use_id: str | None, - context: Any, - ) -> dict[str, Any]: + context: HookContext, + ) -> HookJSONOutput: """Adapter for pre_tool_use hooks.""" - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - pre_result = await hooks.run_pre_tool_hooks( agent_name=agent_name, - tool_name=tool_name, - tool_input=tool_input, + tool_name=input_data["tool_name"], + tool_input=input_data["tool_input"], session_id=input_data.get("session_id"), env=env, ) - # Convert our hook result to SDK format decision = pre_result.get("decision") if decision == "deny": @@ -473,7 +474,7 @@ async def on_pre_tool_use( } # Check for modified input - output: dict[str, Any] = {} + output: SyncHookJSONOutput = {} if modified := pre_result.get("modified_input"): output["hookSpecificOutput"] = { "hookEventName": "PreToolUse", @@ -482,25 +483,21 @@ async def on_pre_tool_use( return output - result["PreToolUse"] = [HookMatcher(matcher="*", hooks=[on_pre_tool_use])] # type: ignore[list-item] + result["PreToolUse"] = [HookMatcher(matcher="*", hooks=[on_pre_tool_use])] # ty:ignore[invalid-argument-type] # pyright: ignore[reportArgumentType] if hooks.post_tool_use: async def on_post_tool_use( - input_data: Any, + input_data: PostToolUseHookInput, tool_use_id: str | None, - context: Any, + context: HookContext, ) -> dict[str, Any]: """Adapter for post_tool_use hooks.""" - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - tool_response = input_data.get("tool_response") - await hooks.run_post_tool_hooks( agent_name=agent_name, - tool_name=tool_name, - tool_input=tool_input, - tool_output=tool_response, + tool_name=input_data["tool_name"], + tool_input=input_data["tool_input"], + tool_output=input_data["tool_response"], duration_ms=0, # SDK doesn't provide timing session_id=input_data.get("session_id"), env=env, @@ -509,6 +506,6 @@ async def on_post_tool_use( # Post hooks are observation-only in SDK, can add context return {} - result["PostToolUse"] = [HookMatcher(matcher="*", hooks=[on_post_tool_use])] # type: ignore[list-item] + result["PostToolUse"] = [HookMatcher(matcher="*", hooks=[on_post_tool_use])] # ty:ignore[invalid-argument-type] # pyright: ignore[reportArgumentType] return result diff --git a/src/agentpool/agents/claude_code_agent/hook_manager.py b/src/agentpool/agents/claude_code_agent/hook_manager.py index 2d38c32f0..77dd5c153 100644 --- a/src/agentpool/agents/claude_code_agent/hook_manager.py +++ b/src/agentpool/agents/claude_code_agent/hook_manager.py @@ -44,7 +44,7 @@ def __init__( self, *, agent_name: str, - agent_hooks: AgentHooks | None = None, + agent_hooks: AgentHooks, injection_manager: PromptInjectionManager | None = None, set_mode: Callable[[str, str], Awaitable[None]] | None = None, env: ExecutionEnvironment | None = None, @@ -82,15 +82,12 @@ def build_hooks(self) -> dict[HookEvent, list[HookMatcher]]: # Add PostToolUse hook for injection result["PostToolUse"] = [HookMatcher(matcher="*", hooks=[self._on_post_tool_use])] # Merge AgentHooks if present - if self.agent_hooks: - agent_hooks = build_sdk_hooks_from_agent_hooks( - self.agent_hooks, self.agent_name, env=self._env - ) - for event_name, matchers in agent_hooks.items(): - if event_name in result: - result[event_name].extend(matchers) - else: - result[event_name] = matchers + agent_hooks = build_sdk_hooks_from_agent_hooks(self.agent_hooks, self.agent_name, self._env) + for event_name, matchers in agent_hooks.items(): + if event_name in result: + result[event_name].extend(matchers) + else: + result[event_name] = matchers return result @@ -109,14 +106,14 @@ async def _on_post_tool_use( result: SyncHookJSONOutput = {"continue_": True} # Consume pending injection from shared manager + tool_name = input_data.get("tool_name", "unknown") if self._injection_manager and (injection := await self._injection_manager.consume()): - tool_name = input_data.get("tool_name", "unknown") logger.debug("Injecting context after tool use", agent=self.agent_name, tool=tool_name) result["hookSpecificOutput"] = PostToolUseHookSpecificOutput( hookEventName="PostToolUse", additionalContext=injection, ) - if input_data.get("tool_name") == "EnterPlanMode" and self._set_mode: + if tool_name == "EnterPlanMode" and self._set_mode: await self._set_mode("plan", "mode") return result diff --git a/src/agentpool/agents/claude_code_agent/mcp_manager.py b/src/agentpool/agents/claude_code_agent/mcp_manager.py index d3d0bf7a6..6e57e2c27 100644 --- a/src/agentpool/agents/claude_code_agent/mcp_manager.py +++ b/src/agentpool/agents/claude_code_agent/mcp_manager.py @@ -34,9 +34,7 @@ def servers(self) -> dict[str, McpServerConfig]: def add_server_config(self, cfg: MCPServerConfig | str) -> str: """Add a server config. Accepts MCPServerConfig or string shorthand.""" - from agentpool.agents.claude_code_agent.converters import ( - convert_mcp_servers_to_sdk_format, - ) + from agentpool.agents.claude_code_agent.converters import convert_mcp_servers_to_sdk_format resolved = BaseMCPServerConfig.from_string(cfg) if isinstance(cfg, str) else cfg sdk_configs = convert_mcp_servers_to_sdk_format([resolved]) diff --git a/src/agentpool/agents/claude_code_agent/static_info.py b/src/agentpool/agents/claude_code_agent/static_info.py index 425afb741..76e8abc54 100644 --- a/src/agentpool/agents/claude_code_agent/static_info.py +++ b/src/agentpool/agents/claude_code_agent/static_info.py @@ -74,25 +74,25 @@ MODES = [ ModeInfo( - id="default", + value="default", name="Default", description="Require confirmation for tool usage", category_id="mode", ), ModeInfo( - id="acceptEdits", + value="acceptEdits", name="Accept Edits", description="Auto-approve file edits without confirmation", category_id="mode", ), ModeInfo( - id="plan", + value="plan", name="Plan", description="Planning mode - no tool execution", category_id="mode", ), ModeInfo( - id="bypassPermissions", + value="bypassPermissions", name="Bypass Permissions", description="Skip all permission checks (use with caution)", category_id="mode", @@ -111,33 +111,60 @@ # ), ] +EFFORT_MODES = [ + ModeInfo( + value="low", + name="Low", + description="Faster, cheaper responses for straightforward tasks", + category_id="effort", + ), + ModeInfo( + value="medium", + name="Medium", + description="Balanced reasoning effort", + category_id="effort", + ), + ModeInfo( + value="high", + name="High", + description="Deeper reasoning for complex tasks", + category_id="effort", + ), + ModeInfo( + value="max", + name="Max", + description="Maximum reasoning depth", + category_id="effort", + ), +] + THINKING_MODES = [ ModeInfo( - id="off", + value="off", name="Off", description="No extended thinking", category_id="thought_level", ), ModeInfo( - id="4k", + value="4k", name="4K tokens", description="Light reasoning (4,096 tokens)", category_id="thought_level", ), ModeInfo( - id="8k", + value="8k", name="8K tokens", description="Moderate reasoning (8,192 tokens)", category_id="thought_level", ), ModeInfo( - id="16k", + value="16k", name="16K tokens", description="Deep reasoning (16,384 tokens)", category_id="thought_level", ), ModeInfo( - id="32k", + value="32k", name="32K tokens", description="Maximum reasoning (32,768 tokens)", category_id="thought_level", @@ -152,7 +179,7 @@ def get_id(m: ModelInfo) -> str: return m.id_override or m.id modes = [ - ModeInfo(id=get_id(m), name=m.name, description=m.description or "", category_id="model") + ModeInfo(value=get_id(m), name=m.name, description=m.description or "", category_id="model") for m in models ] diff --git a/src/agentpool/agents/claude_code_agent/stream_adapter.py b/src/agentpool/agents/claude_code_agent/stream_adapter.py new file mode 100644 index 000000000..a874761b1 --- /dev/null +++ b/src/agentpool/agents/claude_code_agent/stream_adapter.py @@ -0,0 +1,375 @@ +"""Stream adapter for converting Claude SDK messages to agentpool events. + +Tool Call Event Flow +-------------------- +The SDK streams events in a specific order. Understanding this is critical for +avoiding race conditions with permission dialogs: + +1. **content_block_start** (StreamEvent) + - Contains tool_use_id, tool name + - We emit ToolCallStartEvent here (early, with empty args) + - ACP converter sends `tool_call` notification to client + +2. **content_block_delta** (StreamEvent, multiple) + - Contains input_json_delta with partial JSON args + - We emit PartDeltaEvent(ToolCallPartDelta) for streaming + - ACP converter accumulates args, doesn't send notifications + +3. **AssistantMessage** with ToolUseBlock + - Contains complete tool call info (id, name, full args) + - We do NOT emit events here (would race with permission) + - Just track file modifications silently + +4. **content_block_stop**, **message_delta**, **message_stop** (StreamEvent) + - Signal completion of the message + +5. **can_use_tool callback** (~100ms after message_stop) + - SDK calls our permission callback + - We send permission request to ACP client + - Client shows permission dialog to user + - IMPORTANT: No notifications should be sent while dialog is open! + +6. **Tool execution or denial** + - If allowed: tool runs, emits ToolCallCompleteEvent + - If denied: SDK receives denial, continues with next turn +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import re +from typing import TYPE_CHECKING, Any, assert_never, cast + +from clawd_code_sdk.models import ( + AuthStatusMessage, + ElicitationCompleteMessage, + FilesPersistedSystemMessage, + HookProgressSystemMessage, + HookResponseSystemMessage, + HookStartedSystemMessage, + ImageBlock, + InitSystemMessage, + LocalCommandOutputMessage, + PromptSuggestionMessage, + RateLimitMessage, + ResultErrorMessage, + ResultSuccessMessage, + SessionStateChangedMessage, + StatusSystemMessage, + TaskNotificationSystemMessage, + TaskProgressSystemMessage, + TaskStartedSystemMessage, + ToolProgressMessage, + ToolUseSummaryMessage, +) +from pydantic_ai import FunctionToolResultEvent, PartEndEvent, TextPart, ToolReturnPart + +from agentpool.agents.claude_code_agent.converters import convert_to_opencode_metadata +from agentpool.agents.events import ( + CompactionEvent, + PartDeltaEvent, + PartStartEvent, + TerminalContentItem, + ToolCallCompleteEvent, + ToolCallProgressEvent, + ToolCallStartEvent, +) +from agentpool.agents.events.infer_info import derive_rich_tool_info +from agentpool.utils.streams.streamed_response import StreamedResponse +from agentpool.utils.time_utils import get_now + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterator + from datetime import datetime + + from anthropic.types.beta import BetaRawContentBlockDelta + from clawd_code_sdk import Message, ResultMessage, ToolUseBlock + from clawd_code_sdk.models import StopReason + + from agentpool.agents.events import ( + RichAgentStreamEvent, + ) + from agentpool.agents.events.events import ToolCallContentItem + +_MCP_TOOL_PATTERN = re.compile(r"^mcp__agentpool-(.+)-tools__(.+)$") +"""Pattern to detect CC-provided tool names.""" + + +def _strip_mcp_prefix(tool_name: str) -> str: + """Strip MCP server prefix from tool names for cleaner UI display.""" + if match := _MCP_TOOL_PATTERN.match(tool_name): + return match.group(2) + return tool_name + + +@dataclass(kw_only=True) +class ClaudeCodeStreamedResponse(StreamedResponse): + """Streamed codex response.""" + + stream: AsyncIterator[Message | RichAgentStreamEvent[Any]] + tool_metadata: dict[str, dict[str, Any]] + agent_name: str + session_id: str + _timestamp: datetime = field(default_factory=get_now) + _model_name: str | None = None + _result_message: ResultMessage | None = None + """The SDK ResultMessage captured at end of stream (contains usage, cost, etc.).""" + + async def _get_event_iterator(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: # noqa: PLR0915 + from anthropic.types.beta import ( + BetaRawContentBlockDeltaEvent as ContentBlockDeltaEvent, + BetaRawContentBlockStartEvent as ContentBlockStartEvent, + BetaRawContentBlockStopEvent as ContentBlockStopEvent, + BetaTextBlock as AnthTextBlock, + BetaThinkingBlock as AnthThinkingBlock, + BetaToolUseBlock as AnthToolUseBlock, + ) + from clawd_code_sdk.models import ( + APIRetrySystemMessage, + AssistantMessage, + CompactBoundarySystemMessage, + MessageUnion, + ResultMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, + ) + + pending_tool_calls: dict[str, ToolUseBlock] = {} + streaming_tc_id: str | None = None + # Note: message reconstruction (model_messages, response_parts, text accumulation) + # is handled by the caller's MessageReconstructor, which observes the yielded events. + + async for event_or_message in self.stream: + if not isinstance(event_or_message, MessageUnion): + yield event_or_message + continue + message = event_or_message + match message: + case AssistantMessage(model=model, content=msg_content): + if model: + self._model_name = model + for block in msg_content: + match block: + case ToolUseBlock(id=tc_id, name=name, input=input_data): + pending_tool_calls[tc_id] = block + # Emit progress update with complete args + # (ToolCallStartEvent was already emitted via streaming + # with empty args; now we have the full picture) + rich_info = derive_rich_tool_info(name, input_data) + yield ToolCallProgressEvent( + tool_call_id=tc_id, + tool_name=_strip_mcp_prefix(name), + title=rich_info.title, + tool_input=cast(dict[str, Any], input_data), + ) + case ToolResultBlock() | ThinkingBlock() | TextBlock() | ImageBlock(): + pass # ToolResult Blocks only appear in UserMessages + case _ as unknown_block: + assert_never(unknown_block) # ty:ignore[type-assertion-failure] + + case UserMessage(content=list() as user_blocks): + for user_block in user_blocks: + if not isinstance(user_block, ToolResultBlock): + continue + tc_id = user_block.tool_use_id + result_content = user_block.get_parsed_content() + # Flush + tool return handled by reconstructor via + # ToolCcleanupallCompleteEvent observation + tool_use = pending_tool_calls.pop(tc_id) + stripped = _strip_mcp_prefix(tool_use.name) + # For Bash tools: stream output + exit to virtual terminal + # before signaling completion. This matches the 3-step + # display-only terminal lifecycle. + if tool_use.name == "Bash": + output_str = str(result_content) if result_content else "" + exit_code = 1 if user_block.is_error else 0 + yield ToolCallProgressEvent( + tool_call_id=tc_id, + tool_name=stripped, + field_meta={ + "terminal_output": { + "terminal_id": tc_id, + "data": output_str, + }, + }, + ) + yield ToolCallProgressEvent( + tool_call_id=tc_id, + tool_name=stripped, + field_meta={ + "terminal_exit": { + "terminal_id": tc_id, + "exit_code": exit_code, + "signal": None, + }, + }, + ) + + return_part = ToolReturnPart( + tool_name=stripped, + content=result_content, + tool_call_id=tc_id, + ) + yield FunctionToolResultEvent(result=return_part) + tool_input = cast(dict[str, Any], tool_use.input) if tool_use else {} + metadata: dict[str, Any] | None = self.tool_metadata.get(tc_id) + if not metadata and isinstance(message.tool_use_result, list): + oc_metadata = convert_to_opencode_metadata( + tool_name=tool_use.name, + tool_use_result=i[0] if (i := message.tool_use_result) else {}, + tool_input=tool_input, + ) + metadata = cast(dict[str, Any] | None, oc_metadata) + + yield ToolCallCompleteEvent( + tool_name=stripped, + tool_call_id=tc_id, + tool_input=tool_input, + tool_result=result_content, + agent_name=self.agent_name, + message_id="", + metadata=metadata, + ) + + # Real-time streaming: content_block_start + case StreamEvent( + event=ContentBlockStartEvent(index=idx, content_block=content_block) + ): + match content_block: + case AnthTextBlock(): + yield PartStartEvent.text(index=idx, content="") + case AnthThinkingBlock(): + yield PartStartEvent.thinking(index=idx, content="") + case AnthToolUseBlock(id=tc_id, name=raw_tool_name, input=input_): + tool_name = _strip_mcp_prefix(raw_tool_name) + streaming_tc_id = tc_id + rich_info = derive_rich_tool_info(raw_tool_name, input_) + # For Bash tools: signal client to create a display-only + # terminal. Claude Code executes commands server-side, so + # we use the _meta virtual terminal convention instead of + # the ACP terminal/create RPC. + is_bash = raw_tool_name == "Bash" + if is_bash: + tc_content: list[ToolCallContentItem] = [ + TerminalContentItem(terminal_id=tc_id), + ] + meta: dict[str, Any] | None = { + "terminal_info": {"terminal_id": tc_id}, + } + else: + tc_content = rich_info.content + meta = None + yield ToolCallStartEvent( + tool_call_id=tc_id, + tool_name=tool_name, + title=rich_info.title, + kind=rich_info.kind, + locations=[], + content=tc_content, + raw_input=input_, + field_meta=meta, + ) + + # content_block_delta events + case StreamEvent(event=ContentBlockDeltaEvent(index=index, delta=delta)): + for e in handle_delta( + index=index, delta=delta, streaming_tc_id=streaming_tc_id + ): + yield e + # content_block_stop + case StreamEvent(event=ContentBlockStopEvent(index=index)): + streaming_tc_id = None + yield PartEndEvent(index=index, part=TextPart(content="")) + + case StatusSystemMessage(status="compacting"): + yield CompactionEvent( + session_id=self.session_id, trigger="auto", phase="starting" + ) + + case CompactBoundarySystemMessage(compact_metadata=compact_metadata): + yield CompactionEvent( + session_id=self.session_id, + trigger=compact_metadata["trigger"], + phase="completed", + pre_tokens=compact_metadata["pre_tokens"], + ) + + case ( + StreamEvent() + | UserMessage() + | PromptSuggestionMessage() + | ResultSuccessMessage() + | ResultErrorMessage() + | HookStartedSystemMessage() + | HookProgressSystemMessage() + | HookResponseSystemMessage() + | RateLimitMessage() + | AuthStatusMessage() + | ToolProgressMessage() + | ToolUseSummaryMessage() + | InitSystemMessage() + | StatusSystemMessage() + | TaskStartedSystemMessage() + | TaskProgressSystemMessage() + | TaskNotificationSystemMessage() + | FilesPersistedSystemMessage() + | SessionStateChangedMessage() + | ElicitationCompleteMessage() + | LocalCommandOutputMessage() + | APIRetrySystemMessage() + ): + pass + case _ as unreachable: + assert_never(unreachable) + + # Check for result (end of response) + if isinstance(message, ResultMessage): + self._result_message = message + + @property + def model_name(self) -> str: + """Get the model name of the response.""" + assert self._model_name + return self._model_name + + @property + def timestamp(self) -> datetime: + """Get the timestamp of the response.""" + return self._timestamp + + @property + def stop_reason(self) -> StopReason | None: + """Extract stop reason from result message.""" + return self._result_message.stop_reason if self._result_message else None + + +def handle_delta( + index: int, + delta: BetaRawContentBlockDelta, + streaming_tc_id: str | None, +) -> Iterator[PartDeltaEvent]: + from anthropic.types.beta import ( + BetaCitationsDelta as CitationsDelta, + BetaCompactionContentBlockDelta as CompactionContentBlockDelta, + BetaInputJSONDelta as InputJSONDelta, + BetaSignatureDelta as SignatureDelta, + BetaTextDelta as TextDelta, + BetaThinkingDelta as ThinkingDelta, + ) + + match delta: + case TextDelta(text=text): + yield PartDeltaEvent.text(index=index, content=text) + case ThinkingDelta(thinking=thinking): + yield PartDeltaEvent.thinking(index=index, content=thinking) + case InputJSONDelta(partial_json=json_) if json_ and streaming_tc_id: + yield PartDeltaEvent.tool_call(index, content=json_, tool_call_id=streaming_tc_id) + case CitationsDelta() | SignatureDelta() | InputJSONDelta() | CompactionContentBlockDelta(): + pass + case _ as unreachable: + assert_never(unreachable) diff --git a/src/agentpool/agents/codex_agent/codex_agent.py b/src/agentpool/agents/codex_agent/codex_agent.py index 7d835b75b..f980aa06f 100644 --- a/src/agentpool/agents/codex_agent/codex_agent.py +++ b/src/agentpool/agents/codex_agent/codex_agent.py @@ -4,27 +4,34 @@ from decimal import Decimal from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, assert_never, cast from uuid import uuid4 import anyenv +from codexed.models import ( + ApprovalPolicy, + CommandExecutionRequestApprovalResponse, + McpServerElicitationResponse, + Personality, + ReasoningEffort, + SandboxMode, +) from pydantic import TypeAdapter -from pydantic_ai import TextPartDelta -from pydantic_ai.usage import RequestUsage +from pydantic_ai import RunUsage from agentpool.agents.base_agent import BaseAgent from agentpool.agents.codex_agent.codex_converters import ( - convert_codex_stream, mcp_config_to_codex, to_finish_reason, to_model_info, - to_request_usage, to_run_usage, to_session_data, turns_to_chat_messages, user_content_to_codex, ) -from agentpool.agents.events import PartDeltaEvent, RunStartedEvent, StreamCompleteEvent +from agentpool.agents.codex_agent.stream_adapter import CodexStreamedResponse +from agentpool.agents.events import RunStartedEvent, StreamCompleteEvent +from agentpool.agents.events.reconstructor import MessageReconstructor from agentpool.agents.exceptions import ( AgentNotInitializedError, UnknownCategoryError, @@ -38,10 +45,20 @@ from collections.abc import AsyncIterator, Sequence from types import TracebackType + from codexed import CodexClient, Session + from codexed.models import ( + McpServerConfig, + McpServerElicitationRequestParams, + TokenUsageBreakdown, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, + ) + from codexed.request_handlers import ApprovalParams, ApprovalResponse from exxec import ExecutionEnvironment from pydantic_ai import UserContent from tokonomics.model_discovery.model_info import ModelInfo + from agentpool.agents.context import ConfirmationResult from agentpool.agents.events import RichAgentStreamEvent from agentpool.agents.modes import ModeCategory from agentpool.common_types import AnyEventHandlerType, MCPServerStatus, StrPath @@ -53,15 +70,6 @@ from agentpool.sessions.models import SessionData from agentpool.ui.base import InputProvider from agentpool_config.mcp_server import MCPServerConfig - from codex_adapter import ApprovalPolicy, CodexClient, Personality, ReasoningEffort, SandboxMode - from codex_adapter.models import ( - CodexEvent, - McpServerConfig, - MiscTurnStatusValue, - TokenUsageBreakdown, - ToolRequestUserInputParams, - ToolRequestUserInputResponse, - ) logger = get_logger(__name__) @@ -77,69 +85,6 @@ class CodexAgent[TDeps = None, OutputDataT = str](BaseAgent[TDeps, OutputDataT]) AGENT_TYPE: ClassVar = "codex" - async def _on_user_input( - self, - params: ToolRequestUserInputParams, - ) -> ToolRequestUserInputResponse: - """Handle user input requests from Codex server. - - Converts Codex's ToolRequestUserInputParams to MCP ElicitRequestFormParams, - delegates to the input provider's get_elicitation(), and converts back. - - Args: - params: User input request with questions - - Returns: - ToolRequestUserInputResponse with answers - """ - from mcp.types import ElicitRequestFormParams, ElicitResult, ErrorData - - from codex_adapter.models import ( - ToolRequestUserInputAnswer as _Answer, - ToolRequestUserInputResponse as _Response, - ) - - if self._tool_bridge._current_context is None: - raise RuntimeError("User input callback invoked outside of an active run") - - input_provider = self._tool_bridge._current_context.get_input_provider() - answers: dict[str, _Answer] = {} - - for question in params.questions: - # Build a JSON schema property for this question - schema: dict[str, Any] = { - "type": "object", - "properties": {question.id: question.to_schema_property()}, - "required": [question.id], - } - - # Build display message from header + question - message = ( - f"{question.header}: {question.question}" if question.header else question.question - ) - mcp_params = ElicitRequestFormParams(message=message, requestedSchema=schema) - result = await input_provider.get_elicitation(params=mcp_params) - - if isinstance(result, ErrorData): - # Error - return empty answers for remaining questions - answers[question.id] = _Answer(answers=[]) - continue - - if isinstance(result, ElicitResult): - if result.action == "accept" and result.content: - raw_value = result.content.get(question.id) - if isinstance(raw_value, list): - answers[question.id] = _Answer(answers=raw_value) - elif raw_value is not None: - answers[question.id] = _Answer(answers=[str(raw_value)]) - else: - answers[question.id] = _Answer(answers=[]) - else: - # User declined or cancelled - answers[question.id] = _Answer(answers=[]) - - return _Response(answers=answers) - def __init__( self, *, @@ -157,7 +102,7 @@ def __init__( env: ExecutionEnvironment | StrPath | None = None, input_provider: InputProvider | None = None, env_vars: dict[str, str] | None = None, - output_type: type[OutputDataT] = str, # type: ignore[assignment] + output_type: type[OutputDataT] = str, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] event_handlers: Sequence[AnyEventHandlerType] | None = None, hooks: AgentHooks | None = None, session_id: str | None = None, @@ -218,6 +163,7 @@ def __init__( # Client state self._client: CodexClient | None = None self._sdk_session_id: str | None = session_id + self._sessions: dict[str, Session] = {} self._external_mcp_servers = [ BaseMCPServerConfig.from_string(s) if isinstance(s, str) else s for s in mcp_servers or [] @@ -229,7 +175,7 @@ def __init__( self._current_effort: ReasoningEffort | None = reasoning_effort self._current_sandbox: SandboxMode | None = sandbox self._current_personality: Personality | None = personality - self._current_turn_id: str | None = None + self._adapter: CodexStreamedResponse | None = None # Populated by capture_metadata during streaming, read after stream completes self._token_usage_data: TokenUsageBreakdown | None = None # Pass injection_manager for mid-run injection support @@ -286,7 +232,7 @@ def from_config( async def _setup_toolsets(self) -> None: """Setup toolsets and start the tool bridge.""" - from codex_adapter.models.mcp_server import HttpMcpServer as CodexHttpMcpServer + from codexed.models import HttpMcpServer as CodexHttpMcpServer if not self._toolsets: return @@ -304,7 +250,7 @@ async def _setup_toolsets(self) -> None: async def __aenter__(self) -> Self: """Start Codex client and create or resume thread.""" - from codex_adapter import CodexClient + from codexed import CodexClient await super().__aenter__() await self._setup_toolsets() @@ -314,15 +260,21 @@ async def __aenter__(self) -> Self: mcp_config_to_codex(c) for c in self._external_mcp_servers ) # Create and connect client with MCP servers and elicitation callback - self._client = CodexClient(mcp_servers=mcp_servers_dict, on_user_input=self._on_user_input) + self._client = CodexClient( + mcp_servers=mcp_servers_dict, + on_user_input=self._on_user_input, + on_mcp_elicitation=self._on_mcp_elicitation, + on_approval=self._on_approval, + ) await self._client.__aenter__() cwd = str(self.env.cwd or Path.cwd()) # Resume existing session or start new thread if self._sdk_session_id: # Resume the specified thread - response = await self._client.thread_resume(self._sdk_session_id) - thread = response.thread + session = await self._client.thread_resume(self._sdk_session_id) + thread = session.response.thread self._sdk_session_id = thread.id + self._sessions[self._sdk_session_id] = session self.log.info("Codex thread resumed", sdk_session_id=self._sdk_session_id, cwd=cwd) # Restore conversation history from resumed thread chat_messages = turns_to_chat_messages(thread.turns) @@ -331,7 +283,7 @@ async def __aenter__(self) -> Self: self.log.info("Restored conversation history", turn_count=len(thread.turns)) else: # Start a new thread - response = await self._client.thread_start( + session = await self._client.thread_start( cwd=cwd, model=self._current_model, base_instructions=self._base_instructions, @@ -340,7 +292,8 @@ async def __aenter__(self) -> Self: approval_policy=self._approval_policy, personality=self._current_personality, ) - self._sdk_session_id = response.thread.id + self._sdk_session_id = session.thread_id + self._sessions[self._sdk_session_id] = session self.log.info("Codex thread started", sdk_session_id=self._sdk_session_id, cwd=cwd) return self @@ -354,6 +307,114 @@ async def __aexit__( await self._cleanup() await super().__aexit__(exc_type, exc_val, exc_tb) + async def _on_approval(self, data: ApprovalParams) -> ApprovalResponse: + from codexed.models import ( + CommandExecutionRequestApprovalParams, + FileChangeRequestApprovalParams, + SkillRequestApprovalParams, + ) + from codexed.models.misc import SkillRequestApprovalResponse + from codexed.models.responses import FileChangeRequestApprovalResponse + + self.log.debug("Permission request") + ctx = self._tool_bridge._current_context + if ctx is None: + raise RuntimeError("Permission callback invoked outside of an active run") + input_provider = ctx.get_input_provider() + result = await input_provider.get_tool_confirmation(ctx) + mapping: dict[ConfirmationResult, Literal["allow"]] = { + "allow": "allow", + "skip": "allow", + "abort_run": "allow", + "abort_chain": "allow", + } + approval_decision = mapping[result] + # Auto-grant if bypassPermissions mode is active + match data: + case CommandExecutionRequestApprovalParams(): + return CommandExecutionRequestApprovalResponse(decision=approval_decision) + case SkillRequestApprovalParams(): + return SkillRequestApprovalResponse(decision=approval_decision) + case FileChangeRequestApprovalParams(): + return FileChangeRequestApprovalResponse(decision=approval_decision) + case _ as unreachable: + assert_never(unreachable) + + async def _on_mcp_elicitation( + self, data: McpServerElicitationRequestParams + ) -> McpServerElicitationResponse: + from mcp.types import ErrorData + + ctx = self._tool_bridge._current_context + if ctx is None: + raise RuntimeError("MCP elicitation callback invoked outside of an active run") + provider = ctx.get_input_provider() + mcp_request = data.to_mcp() + result = await provider.get_elicitation(mcp_request) + if isinstance(result, ErrorData): + return McpServerElicitationResponse(action="cancel") + return McpServerElicitationResponse(action=result.action, content=result.content) + + async def _on_user_input( + self, + params: ToolRequestUserInputParams, + ) -> ToolRequestUserInputResponse: + """Handle user input requests from Codex server. + + Converts Codex's ToolRequestUserInputParams to MCP ElicitRequestFormParams, + delegates to the input provider's get_elicitation(), and converts back. + + Args: + params: User input request with questions + + Returns: + ToolRequestUserInputResponse with answers + """ + from codexed.models import ( + ToolRequestUserInputAnswer as _Answer, + ToolRequestUserInputResponse as _Response, + ) + from mcp.types import ElicitRequestFormParams, ElicitResult, ErrorData + + if self._tool_bridge._current_context is None: + raise RuntimeError("User input callback invoked outside of an active run") + + input_provider = self._tool_bridge._current_context.get_input_provider() + answers: dict[str, _Answer] = {} + for question in params.questions: + # Build a JSON schema property for this question + props = {question.id: question.to_schema_property()} + schema = {"type": "object", "properties": props, "required": [question.id]} + # Build display message from header + question + message = ( + f"{question.header}: {question.question}" if question.header else question.question + ) + mcp_params = ElicitRequestFormParams(message=message, requestedSchema=schema) + result = await input_provider.get_elicitation(params=mcp_params) + + match result: + case ErrorData(): + answers[question.id] = _Answer(answers=[]) + continue + case ElicitResult(action="accept", content=content) if content: + raw_value = content.get(question.id) + match raw_value: + case list(): + answers[question.id] = _Answer(answers=raw_value) + case None: + answers[question.id] = _Answer(answers=[]) + case str() | int() | float() | bool(): + answers[question.id] = _Answer(answers=[str(raw_value)]) + case _ as unknown_type: + assert_never(unknown_type) # ty:ignore[type-assertion-failure] + case ElicitResult(): + # User declined or cancelled + answers[question.id] = _Answer(answers=[]) + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] + + return _Response(answers=answers) + async def get_mcp_server_info(self) -> dict[str, MCPServerStatus]: """Get MCP server status from connected Codex client. @@ -394,6 +455,7 @@ async def _cleanup(self) -> None: self.log.exception("Error closing Codex client") self._client = None self._sdk_session_id = None + self._sessions.clear() async def _stream_events( # noqa: PLR0915 self, @@ -413,16 +475,11 @@ async def _stream_events( # noqa: PLR0915 """Stream events from Codex turn execution.""" from agentpool.agents.events import PlanUpdateEvent from agentpool.messaging.messages import TokenCost - from codex_adapter.models.events import ( - ThreadTokenUsageUpdatedEvent, - TurnCompletedEvent, - TurnStartedEvent, - ) if not self._client or not self._sdk_session_id: raise AgentNotInitializedError - input_items = user_content_to_codex(prompts) + input_items = list(user_content_to_codex(prompts)) # Generate IDs if not provided run_id = str(uuid4()) final_message_id = message_id or str(uuid4()) @@ -435,72 +492,48 @@ async def _stream_events( # noqa: PLR0915 if self.storage and self.session_id and self._sdk_session_id: await self.storage.update_sdk_session_id(self.session_id, self._sdk_session_id) # Stream turn events with bridge context set - accumulated_text: list[str] = [] - self._token_usage_data = None - self._turn_status: MiscTurnStatusValue | None = None + reconstructor = MessageReconstructor(initial_prompts=prompts, model_name=self.model_name) # Pass output type directly - adapter handles conversion to JSON schema - - async def capture_metadata( - raw_events: AsyncIterator[CodexEvent], - ) -> AsyncIterator[CodexEvent]: - """Wrapper to capture token usage, turn_id, and turn status before event conversion.""" - async for event in raw_events: - match event: - case TurnStartedEvent(data=data): - self._current_turn_id = data.turn.id - case TurnCompletedEvent(data=data): - self._turn_status = data.turn.status - case ThreadTokenUsageUpdatedEvent(data=data): - self._token_usage_data = data.token_usage.last - yield event - + # Resolve input provider: explicit parameter overrides agent default + effective_input_provider = input_provider or self._input_provider + run_context = self.get_context(data=deps, input_provider=effective_input_provider) + session = self._sessions[self._sdk_session_id] + raw_stream = session.turn_stream( + input_items, + model=self._current_model, + effort=self._current_effort, + approval_policy=self._approval_policy, + sandbox_policy=self._current_sandbox, + output_schema=None if self._output_type in (str, None) else self._output_type, + personality=self._current_personality, + ) + self._adapter = CodexStreamedResponse(stream=raw_stream) try: - # Resolve input provider: explicit parameter overrides agent default - effective_input_provider = input_provider or self._input_provider - run_context = self.get_context(data=deps, input_provider=effective_input_provider) async with self._tool_bridge.set_run_context(run_context, prompt=prompts): - raw_stream = self._client.turn_stream( - self._sdk_session_id, - input_items, - model=self._current_model, - effort=self._current_effort, - approval_policy=self._approval_policy, - sandbox_policy=self._current_sandbox, - output_schema=None if self._output_type in (str, None) else self._output_type, - personality=self._current_personality, - ) # Wrap to capture metadata (turn_id, token usage), then convert - async for native_event in convert_codex_stream(capture_metadata(raw_stream)): + async for native_event in self._adapter: + reconstructor.observe(native_event) yield native_event - - # Handle plan updates - sync to pool.todos if isinstance(native_event, PlanUpdateEvent) and self.agent_pool: - # Replace all entries in pool.todos with Codex plan self.agent_pool.todos.replace_all(native_event.entries) - # Accumulate text for final message - if isinstance(native_event, PartDeltaEvent) and isinstance( - native_event.delta, TextPartDelta - ): - accumulated_text.append(native_event.delta.content_delta) - except Exception as e: self.log.exception("Error during Codex turn", error=str(e)) raise finally: # Clear turn_id when turn completes or errors - self._current_turn_id = None + self._adapter = None # Emit completion event - final_text = "".join(accumulated_text) + reconstructor.flush() + final_text = reconstructor.text_content cost_info: TokenCost | None = None - request_usage = RequestUsage() + run_usage = RunUsage() if usage := self._token_usage_data: run_usage = to_run_usage(usage) # TODO: Calculate actual cost - for now set to 0 - cost_info = TokenCost(token_usage=run_usage, total_cost=Decimal(0)) - request_usage = to_request_usage(usage) + cost_info = TokenCost(total_cost=Decimal(0)) # Parse structured output if output_type is not str final_content: OutputDataT if self._output_type not in (str, None): @@ -510,9 +543,9 @@ async def capture_metadata( except (anyenv.JsonLoadError, ValueError) as e: msg = "Failed to parse structured output, returning raw text" self.log.warning(msg, error=str(e), output_type=self._output_type) - final_content = final_text # type: ignore[assignment] + final_content = final_text # type: ignore[assignment] # ty:ignore[invalid-assignment] else: - final_content = final_text # type: ignore[assignment] + final_content = final_text # type: ignore[assignment] # ty:ignore[invalid-assignment] complete_msg: ChatMessage[OutputDataT] = ChatMessage( content=final_content, @@ -521,9 +554,12 @@ async def capture_metadata( session_id=final_session_id, parent_id=parent_id, cost_info=cost_info, - usage=request_usage, + usage=run_usage, model_name=self.model_name, - finish_reason=to_finish_reason(self._turn_status) if self._turn_status else None, + messages=reconstructor.model_messages, + finish_reason=to_finish_reason(s) + if self._adapter and (s := self._adapter._turn_status) + else None, ) yield StreamCompleteEvent[OutputDataT](message=complete_msg) @@ -552,8 +588,8 @@ def to_structured[NewOutputDataT]( from agentpool.utils.result_utils import to_type self.log.debug("Setting result type", output_type=output_type) - self._output_type = to_type(output_type) # type: ignore[assignment] - return self # type: ignore[return-value] + self._output_type = to_type(output_type) # type: ignore[assignment] # ty:ignore[invalid-assignment] + return self # type: ignore[return-value] # ty:ignore[invalid-return-type] async def set_model(self, model: str) -> None: """Set the model for this agent.""" @@ -569,13 +605,19 @@ async def set_approval_policy(self, policy: ApprovalPolicy) -> None: async def _interrupt(self) -> None: """Call Codex turn_interrupt if there's an active turn.""" - if self._client and self._sdk_session_id and self._current_turn_id: + if ( + self._client + and self._sdk_session_id + and self._adapter + and self._adapter._current_turn_id + ): try: - await self._client.turn_interrupt(self._sdk_session_id, self._current_turn_id) + session = self._sessions[self._sdk_session_id] + await session.turn_interrupt(self._adapter._current_turn_id) self.log.info( "Codex turn interrupted", sdk_session_id=self._sdk_session_id, - turn_id=self._current_turn_id, + turn_id=self._adapter._current_turn_id, ) except Exception: self.log.exception("Failed to interrupt Codex turn") @@ -641,7 +683,7 @@ async def get_modes(self) -> list[ModeCategory]: if models := await self.get_available_models(): model_modes = [ ModeInfo( - id=m.id, + value=m.id, name=m.name or m.id, description=m.description or "", category_id="model", @@ -659,25 +701,26 @@ async def get_modes(self) -> list[ModeCategory]: ) return categories - async def _set_mode(self, mode_id: str, category_id: str) -> None: + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: """Handle approval_policy, reasoning_effort, and model mode switching.""" match category_id: case "mode" if mode_id in VALID_POLICIES: - self._approval_policy = mode_id # type: ignore[assignment] + self._approval_policy = cast(ApprovalPolicy, mode_id) case "mode": raise UnknownModeError(mode_id, VALID_POLICIES) case "thought_level" if mode_id in VALID_EFFORTS: - self._current_effort = mode_id # type: ignore[assignment] + self._current_effort = cast(ReasoningEffort, mode_id) case "thought_level": raise UnknownModeError(mode_id, VALID_EFFORTS) case "model": + assert isinstance(mode_id, str) self._current_model = mode_id case "sandbox" if mode_id in VALID_SANDBOXES: - self._current_sandbox = mode_id # type: ignore[assignment] + self._current_sandbox = cast(SandboxMode, mode_id) case "sandbox": raise UnknownModeError(mode_id, VALID_SANDBOXES) case "personality" if mode_id in VALID_PERSONALITIES: - self._current_personality = mode_id # type: ignore[assignment] + self._current_personality = cast(Personality, mode_id) case "personality": raise UnknownModeError(mode_id, VALID_PERSONALITIES) case _: @@ -723,13 +766,14 @@ async def load_session(self, session_id: str) -> SessionData | None: return None try: - response = await self._client.thread_resume(session_id) + session = await self._client.thread_resume(session_id) except Exception: self.log.exception("Failed to resume Codex thread", session_id=session_id) return None # Update current thread ID - thread = response.thread + thread = session.response.thread self._sdk_session_id = thread.id + self._sessions[self._sdk_session_id] = session self.log.info("Thread resumed from Codex server", sdk_session_id=thread.id) # Convert turns to ChatMessages and populate conversation if thread.turns: diff --git a/src/agentpool/agents/codex_agent/codex_converters.py b/src/agentpool/agents/codex_agent/codex_converters.py index 6532fe3b4..f52a56000 100644 --- a/src/agentpool/agents/codex_agent/codex_converters.py +++ b/src/agentpool/agents/codex_agent/codex_converters.py @@ -10,6 +10,18 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, assert_never, overload +from codexed.models import ( + ThreadItemAgentMessage, + ThreadItemCollabAgentToolCall, + ThreadItemContextCompaction, + ThreadItemDynamicToolCall, + ThreadItemEnteredReviewMode, + ThreadItemExitedReviewMode, + ThreadItemPlan, + ThreadItemReasoning, + ThreadItemUserMessage, + ThreadItemWebSearch, +) from pydantic_ai import ( BinaryContent, BuiltinToolCallPart, @@ -21,10 +33,10 @@ ModelResponse, RequestUsage, RunUsage, + TextContent, TextPart, ThinkingPart, ToolCallPart, - ToolReturnPart, UploadedFile, UserContent, UserPromptPart, @@ -32,54 +44,39 @@ from agentpool.messaging import ChatMessage from agentpool.sessions import SessionData -from codex_adapter.models import ( - ThreadItemAgentMessage, - ThreadItemCollabAgentToolCall, - ThreadItemContextCompaction, - ThreadItemDynamicToolCall, - ThreadItemEnteredReviewMode, - ThreadItemExitedReviewMode, - ThreadItemPlan, - ThreadItemReasoning, - ThreadItemUserMessage, - ThreadItemWebSearch, -) if TYPE_CHECKING: - from collections.abc import AsyncIterator + from collections.abc import Iterator, Sequence + from codexed.models import ( + HttpMcpServer, + InputModality, + McpServerConfig, + Model, + StdioMcpServer, + Thread, + ThreadItem, + TokenUsageBreakdown, + Turn, + TurnStatus, + UserInput, + ) from pydantic_ai import FinishReason from tokonomics.model_discovery.model_info import Modality, ModelInfo as TokoModelInfo - from agentpool.agents.events import RichAgentStreamEvent from agentpool_config.mcp_server import ( MCPServerConfig, SSEMCPServerConfig, StdioMCPServerConfig, StreamableHTTPMCPServerConfig, ) - from codex_adapter import TokenUsageBreakdown - from codex_adapter.models import ( - CodexEvent, - HttpMcpServer, - McpServerConfig, - MiscTurnStatusValue, - ModelData, - StdioMcpServer, - ThreadData, - ThreadItem, - Turn, - TurnInputItem, - UserInput, - ) - from codex_adapter.models.codex_types import InputModality _MODALITY_MAP: dict[InputModality, Modality] = {"text": "text", "image": "image"} -def to_finish_reason(status: MiscTurnStatusValue) -> FinishReason: +def to_finish_reason(status: TurnStatus) -> FinishReason: """Convert Codex TurnStatusValue to pydantic-ai FinishReason.""" match status: case "completed": @@ -92,19 +89,19 @@ def to_finish_reason(status: MiscTurnStatusValue) -> FinishReason: return "stop" -def to_run_usage(usage_dict: TokenUsageBreakdown) -> RunUsage: +def to_run_usage(usage: TokenUsageBreakdown) -> RunUsage: return RunUsage( - input_tokens=usage_dict.input_tokens, - output_tokens=usage_dict.output_tokens, - cache_read_tokens=usage_dict.cached_input_tokens, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + cache_read_tokens=usage.cached_input_tokens, ) -def to_request_usage(usage_dict: TokenUsageBreakdown) -> RequestUsage: +def to_request_usage(usage: TokenUsageBreakdown) -> RequestUsage: return RequestUsage( - input_tokens=usage_dict.input_tokens, - output_tokens=usage_dict.output_tokens, - cache_read_tokens=usage_dict.cached_input_tokens, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + cache_read_tokens=usage.cached_input_tokens, ) @@ -135,21 +132,20 @@ def mcp_config_to_codex(config: MCPServerConfig) -> tuple[str, McpServerConfig]: Returns: Tuple of (server name, Codex-compatible MCP server configuration) """ + from codexed.models import HttpMcpServer, StdioMcpServer + from agentpool_config.mcp_server import ( SSEMCPServerConfig, StdioMCPServerConfig, StreamableHTTPMCPServerConfig, ) - from codex_adapter.models.mcp_server import HttpMcpServer, StdioMcpServer # Name should not be None by the time we use it server_name = config.name or f"server_{id(config)}" match config: case StdioMCPServerConfig(command=command, args=args, env=env, enabled=enabled): - return ( - server_name, - StdioMcpServer(command=command, args=args, env=env, enabled=enabled), - ) + stdio_server = StdioMcpServer(command=command, args=args, env=env, enabled=enabled) + return (server_name, stdio_server) case SSEMCPServerConfig(url=url, enabled=enabled): # Codex uses HTTP transport for SSE @@ -160,24 +156,21 @@ def mcp_config_to_codex(config: MCPServerConfig) -> tuple[str, McpServerConfig]: # StreamableHTTP has headers field return (server_name, HttpMcpServer(url=str(url), http_headers=headers, enabled=enabled)) - case _: - raise TypeError(f"Unsupported MCP server config type: {type(config)}") + case _ as unreachable: + raise assert_never(unreachable) -def to_model_info(model_data: ModelData, provider: str = "openai") -> TokoModelInfo: +def to_model_info(model_data: Model, provider: str = "openai") -> TokoModelInfo: from tokonomics.model_discovery.model_info import ModelInfo as TokoModelInfo model_id = model_data.model or model_data.id - input_modalities: set[Modality] = { - _MODALITY_MAP[m] for m in model_data.input_modalities if m in _MODALITY_MAP - } return TokoModelInfo( id=model_id, name=model_data.display_name or model_data.id, provider=provider, description=model_data.description or None, id_override=model_id, - input_modalities=input_modalities or {"text"}, # ty:ignore[invalid-argument-type] + input_modalities={_MODALITY_MAP[m] for m in model_data.input_modalities or []}, metadata={ k: v for k, v in { @@ -191,7 +184,7 @@ def to_model_info(model_data: ModelData, provider: str = "openai") -> TokoModelI ) -def to_session_data(thread_data: ThreadData, agent_name: str, cwd: str | None) -> SessionData: +def to_session_data(thread_data: Thread, agent_name: str, cwd: str | None) -> SessionData: created_at = datetime.fromtimestamp(thread_data.created_at, tz=UTC) return SessionData( session_id=thread_data.id, @@ -203,29 +196,25 @@ def to_session_data(thread_data: ThreadData, agent_name: str, cwd: str | None) - ) -def user_content_to_codex(content: list[UserContent]) -> list[TurnInputItem]: - """Convert pydantic-ai UserContent list to Codex TurnInputItem list.""" - from codex_adapter.models import ImageInputItem, TextInputItem +def user_content_to_codex(content: Sequence[UserContent]) -> Iterator[UserInput]: + """Convert pydantic-ai UserContent list to Codex UserInput list.""" + from codexed.models import ImageUserInput, TextUserInput - result: list[TurnInputItem] = [] for item in content: match item: - case str(): - result.append(TextInputItem(text=item)) + case str(text) | TextContent(content=text): + yield TextUserInput(text=text) case ImageUrl(url=url): - result.append(ImageInputItem(url=url)) + yield ImageUserInput(url=url) case BinaryContent(data=data, media_type=media_type, is_image=is_image) if is_image: - result.append(ImageInputItem.from_bytes(data=data, media_type=media_type)) + yield ImageUserInput.from_bytes(data=data, media_type=media_type) case FileUrl() | BinaryContent() | CachePoint() | UploadedFile(): pass case _ as unreachable: assert_never(unreachable) - return result -async def _format_tool_result( - item: ThreadItem, -) -> str | list[str | BinaryContent]: +async def _format_tool_result(item: ThreadItem) -> str | list[str | BinaryContent]: """Format tool result from a completed ThreadItem. Args: @@ -234,13 +223,14 @@ async def _format_tool_result( Returns: Formatted result string, or list of content items for MCP tool results. """ - from agentpool.mcp_server.conversions import from_mcp_content - from codex_adapter.models import ( + from codexed.models import ( ThreadItemCommandExecution, ThreadItemFileChange, ThreadItemMcpToolCall, ) + from agentpool.mcp_server.conversions import from_mcp_content + match item: case ThreadItemCommandExecution(aggregated_output=output): return f"```\n{output}\n```" or "" @@ -262,47 +252,6 @@ async def _format_tool_result( return "" -async def _thread_item_to_tool_return_part( - item: ThreadItem, -) -> ToolReturnPart | BuiltinToolReturnPart | None: - """Convert a completed ThreadItem to a ToolReturnPart or BuiltinToolReturnPart. - - Codex built-in tools (bash, file changes, web search, etc.) are converted to - BuiltinToolReturnPart since they're provided by the remote Codex agent. - MCP tools are converted to ToolReturnPart (they may be from local ToolBridge). - - Args: - item: Completed thread item from Codex - - Returns: - ToolReturnPart for MCP tools, BuiltinToolReturnPart for Codex built-ins, or None - """ - from codex_adapter.models import ( - ThreadItemCommandExecution, - ThreadItemFileChange, - ThreadItemImageView, - ThreadItemMcpToolCall, - ThreadItemWebSearch, - ) - - result = await _format_tool_result(item) - match item: - case ThreadItemCommandExecution(status="completed", id=tc_id): - return BuiltinToolReturnPart(tool_name="bash", content=result, tool_call_id=tc_id) - case ThreadItemFileChange(status="completed", id=tc_id): - return BuiltinToolReturnPart("file_change", content=result, tool_call_id=tc_id) - case ThreadItemWebSearch(id=tc_id): - return BuiltinToolReturnPart("web_search", content=result, tool_call_id=tc_id) - case ThreadItemImageView(id=tc_id): - return BuiltinToolReturnPart("image_view", content=result, tool_call_id=tc_id) - case ThreadItemMcpToolCall(status="completed", id=tc_id, tool=tool): - # TODO: Distinguish between local (ToolBridge) and remote MCP tools - # See matching TODO in _thread_item_to_tool_call_part - return ToolReturnPart(tool_name=tool, content=result, tool_call_id=tc_id) - case _: - return None - - def _thread_item_to_tool_call_part(item: ThreadItem) -> ToolCallPart | BuiltinToolCallPart | None: """Convert a ThreadItem to a ToolCallPart or BuiltinToolCallPart. @@ -316,7 +265,7 @@ def _thread_item_to_tool_call_part(item: ThreadItem) -> ToolCallPart | BuiltinTo Returns: ToolCallPart for MCP tools, BuiltinToolCallPart for Codex built-ins, or None """ - from codex_adapter.models import ( + from codexed.models import ( ThreadItemCommandExecution, ThreadItemFileChange, ThreadItemImageView, @@ -360,214 +309,43 @@ def _thread_item_to_tool_call_part(item: ThreadItem) -> ToolCallPart | BuiltinTo assert_never(unreachable) -async def convert_codex_stream( # noqa: PLR0915 - events: AsyncIterator[CodexEvent], -) -> AsyncIterator[RichAgentStreamEvent[Any]]: - """Convert Codex event stream to native events with stateful accumulation. - - Args: - events: Async iterator of Codex events from the app-server - - Yields: - Native AgentPool stream events - """ - from agentpool.agents.events import ( - CompactionEvent, - PartDeltaEvent, - PlanUpdateEvent, - TextContentItem, - ToolCallCompleteEvent, - ToolCallProgressEvent, - ToolCallStartEvent, - ) - from agentpool.utils.todos import PlanEntry - from codex_adapter.models import ( - ThreadItemCommandExecution, - ThreadItemFileChange, - ThreadItemMcpToolCall, - ) - from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - CommandExecutionOutputDeltaEvent, - FileChangeOutputDeltaEvent, - ItemCompletedEvent, - ItemStartedEvent, - McpToolCallProgressEvent, - ReasoningTextDeltaEvent, - ThreadCompactedEvent, - TurnPlanUpdatedEvent, - ) - - # Accumulation state for streaming tool outputs - tool_outputs: dict[str, list[str]] = {} - - async for event in events: - match event: - # === Stateful: Accumulate command execution output === - case CommandExecutionOutputDeltaEvent(data=data): - item_id = data.item_id - tool_outputs.setdefault(item_id, []).append(data.delta) - # Emit accumulated progress with replace semantics, wrapped in code block - output = "".join(tool_outputs[item_id]) - items = [TextContentItem(text=f"```\n{output}\n```")] - yield ToolCallProgressEvent(tool_call_id=item_id, items=items, replace_content=True) - - # === File change output delta - ignore the summary, we show diff from item/started === - case FileChangeOutputDeltaEvent(): - # The outputDelta is just "Success. Updated..." summary - not useful - # We already emitted the actual diff content in item/started - pass - - case AgentMessageDeltaEvent(data=data): - yield PartDeltaEvent.text(index=0, content=data.delta) - - case ReasoningTextDeltaEvent(data=data): - yield PartDeltaEvent.thinking(index=0, content=data.delta) - - case ItemStartedEvent(data=data): - if part := _thread_item_to_tool_call_part(data.item): - # Extract title based on tool type - match data.item: - case ThreadItemCommandExecution(command=command): - title = f"Execute: {command}" - case ThreadItemFileChange(changes=changes): - # Build title from file paths - paths = [c.path for c in changes[:3]] # First 3 paths - if len(changes) > 3: # noqa: PLR2004 - title = f"Edit: {', '.join(paths)} (+{len(changes) - 3} more)" - else: - title = f"Edit: {', '.join(paths)}" - case ThreadItemMcpToolCall(tool=tool): - title = f"Call {tool}" - case _: - title = f"Call {part.tool_name}" - - yield ToolCallStartEvent( - tool_call_id=part.tool_call_id, - tool_name=part.tool_name, - title=title, - raw_input=part.args_as_dict(), - ) - - # For file changes, immediately emit the diff as progress - if isinstance(data.item, ThreadItemFileChange): - diff_parts = [] - for change in data.item.changes: - diff_parts.append(f"{change.kind.kind.upper()}: {change.path}") - if change.diff: - diff_parts.append(change.diff) - if diff_parts: - items = [TextContentItem(text="\n".join(diff_parts))] - yield ToolCallProgressEvent(tool_call_id=part.tool_call_id, items=items) - - # === Stateful: Tool/command completed - clean up accumulator === - case ItemCompletedEvent(data=data): - item = data.item - # Clean up accumulated output for this item - tool_outputs.pop(item.id, None) - if part := _thread_item_to_tool_call_part(item): - yield ToolCallCompleteEvent( - tool_name=part.tool_name, - tool_call_id=part.tool_call_id, - tool_input=part.args_as_dict(), - tool_result=await _format_tool_result(item), - agent_name="codex", # Will be overridden by agent - message_id=data.turn_id, - ) - - # === Stateless: MCP tool call progress === - case McpToolCallProgressEvent(data=data): - yield ToolCallProgressEvent(tool_call_id=data.item_id, message=data.message) - - # === Stateless: Thread compacted === - case ThreadCompactedEvent(data=data): - yield CompactionEvent(session_id=data.thread_id, phase="completed") - - # === Stateless: Turn plan updated === - case TurnPlanUpdatedEvent(data=data): - entries = [ - PlanEntry( - content=step.step, - priority="medium", # Codex doesn't provide priority - status="in_progress" if step.status == "inProgress" else step.status, - ) - for step in data.plan - ] - yield PlanUpdateEvent(entries=entries) - - # Ignore other events (token usage, turn started/completed, etc.) - case _: - pass - - -async def event_to_part( - event: CodexEvent, -) -> ( - TextPart - | ThinkingPart - | ToolCallPart - | BuiltinToolCallPart - | ToolReturnPart - | BuiltinToolReturnPart - | None -): - """Convert Codex event to part for message construction. - - This is for building final messages, not for streaming. - - Handles both tool calls (item/started) and tool returns (item/completed). - - Args: - event: Codex event - - Returns: - Part or None - """ - from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - ItemCompletedEvent, - ItemStartedEvent, - ReasoningTextDeltaEvent, - ) - - match event: - case AgentMessageDeltaEvent(data=data): - return TextPart(content=data.delta) - case ReasoningTextDeltaEvent(data=data): - return ThinkingPart(content=data.delta) - case ItemStartedEvent(data=data): - return _thread_item_to_tool_call_part(data.item) - case ItemCompletedEvent(data=data): - return await _thread_item_to_tool_return_part(data.item) - case _: - return None - - def _user_input_to_content(inp: UserInput) -> UserContent: """Convert Codex UserInput to pydantic-ai UserContent.""" - from codex_adapter.models import ( - UserInputImage, - UserInputLocalImage, - UserInputMention, - UserInputSkill, - UserInputText, + from codexed.models import ( + ImageUserInput, + LocalImageUserInput, + MentionUserInput, + SkillUserInput, + TextUserInput, ) match inp: - case UserInputText(): + case TextUserInput(): return inp.text - case UserInputImage(url=url): + case ImageUserInput(url=url): return ImageUrl(url=url) - case UserInputLocalImage(path=path): + case LocalImageUserInput(path=path): return ImageUrl(url=f"file://{path}") - case UserInputSkill(name=name): + case SkillUserInput(name=name): return f"[Skill: {name}]" - case UserInputMention(name=name): + case MentionUserInput(name=name): return f"@{name}" case _ as unreachable: assert_never(unreachable) +def get_tool_parts( + tool_name: str, + args: dict[str, Any], + tc_id: str, + output: str, +) -> tuple[BuiltinToolCallPart, BuiltinToolReturnPart]: + + bash_call = BuiltinToolCallPart(tool_name=tool_name, args=args, tool_call_id=tc_id) + bash_ret = BuiltinToolReturnPart(tool_name=tool_name, content=output, tool_call_id=tc_id) + return (bash_call, bash_ret) + + def _turn_to_chat_messages(turn: Turn) -> list[ChatMessage[list[UserContent]]]: # noqa: PLR0915 """Convert one Turn to ChatMessages (user and optionally assistant). @@ -581,7 +359,7 @@ def _turn_to_chat_messages(turn: Turn) -> list[ChatMessage[list[UserContent]]]: List of ChatMessages - always includes user message, assistant message only if there are assistant responses (handles interrupted/incomplete turns) """ - from codex_adapter.models import ( + from codexed.models import ( ThreadItemAgentMessage, ThreadItemCollabAgentToolCall, ThreadItemCommandExecution, @@ -616,10 +394,8 @@ def _turn_to_chat_messages(turn: Turn) -> list[ChatMessage[list[UserContent]]]: display = f"[Executed: {cmd}]" + (f"\n{output[:200]}" if output else "") assistant_display_parts.append(display) cmd_args = {"command": cmd, "cwd": cwd} - bash_call = BuiltinToolCallPart(tool_name="bash", args=cmd_args, tool_call_id=tc_id) - bash_ret = ToolReturnPart(tool_name="bash", content=output, tool_call_id=tc_id) - assistant_responses.append(ModelResponse(parts=[bash_call])) - assistant_responses.append(ModelRequest(parts=[bash_ret])) + parts = get_tool_parts(tool_name="bash", args=cmd_args, tc_id=tc_id, output=output) + assistant_responses.append(ModelResponse(parts=parts)) case ThreadItemFileChange(changes=changes, id=tc_id): paths = [c.path for c in changes] @@ -630,57 +406,49 @@ def _turn_to_chat_messages(turn: Turn) -> list[ChatMessage[list[UserContent]]]: assistant_display_parts.append(display) diffs = [c.diff for c in changes if c.diff] text = "\n".join(diffs) or "OK" - args = {"files": paths} - edit_call = ToolCallPart(tool_name="edit", args=args, tool_call_id=tc_id) - edit_ret = ToolReturnPart(tool_name="edit", content=text, tool_call_id=tc_id) - assistant_responses.append(ModelResponse(parts=[edit_call])) - assistant_responses.append(ModelRequest(parts=[edit_ret])) + args: dict[str, Any] = {"files": paths} + parts = get_tool_parts(tool_name="edit", args=args, tc_id=tc_id, output=text) + assistant_responses.append(ModelResponse(parts=parts)) - case ThreadItemMcpToolCall(result=mcp_result, arguments=args, id=tc_id, tool=tool): + case ThreadItemMcpToolCall(result=mcp_result, arguments=mcp_args, id=tc_id, tool=tool): result_text = "" if mcp_result and mcp_result.content: texts = [str(b.model_dump().get("text", "")) for b in mcp_result.content] result_text = " ".join(texts) assistant_display_parts.append(f"[Tool: {tool}] {result_text[:100]}") - mcp_args = args if isinstance(args, dict) else {} - mcp_call = BuiltinToolCallPart(tool_name=tool, args=mcp_args, tool_call_id=tc_id) - mcp_ret = ToolReturnPart(tool_name=tool, content=result_text, tool_call_id=tc_id) - assistant_responses.append(ModelResponse(parts=[mcp_call])) - assistant_responses.append(ModelRequest(parts=[mcp_ret])) + args = mcp_args or {} + parts = get_tool_parts(tool_name=tool, args=args, tc_id=tc_id, output=result_text) + assistant_responses.append(ModelResponse(parts=parts)) case ThreadItemWebSearch(query=query, id=tc_id): assistant_display_parts.append(f"[Web Search: {query}]") - search_call = BuiltinToolCallPart( - tool_name="web_search", args={"query": query}, tool_call_id=tc_id - ) - search_ret = ToolReturnPart( - tool_name="web_search", content="Search completed", tool_call_id=tc_id + parts = get_tool_parts( + tool_name="web_search", + args={"query": query}, + tc_id=tc_id, + output="Search completed", ) - assistant_responses.append(ModelResponse(parts=[search_call])) - assistant_responses.append(ModelRequest(parts=[search_ret])) + assistant_responses.append(ModelResponse(parts=parts)) case ThreadItemImageView(path=path, id=tc_id): assistant_display_parts.append(f"[Viewed Image: {path}]") - view_call = BuiltinToolCallPart( - tool_name="view_image", args={"path": path}, tool_call_id=tc_id - ) - view_ret = ToolReturnPart( - tool_name="view_image", content="Image viewed", tool_call_id=tc_id + parts = get_tool_parts( + tool_name="view_image", + args={"path": path}, + tc_id=tc_id, + output="Image viewed", ) - assistant_responses.append(ModelResponse(parts=[view_call])) - assistant_responses.append(ModelRequest(parts=[view_ret])) + assistant_responses.append(ModelResponse(parts=parts)) case ThreadItemEnteredReviewMode(review=review): assistant_display_parts.append(f"[Entered Review Mode: {review}]") - assistant_responses.append( - ModelResponse(parts=[TextPart(content=f"Entered review mode: {review}")]) - ) + tp = TextPart(content=f"Entered review mode: {review}") + assistant_responses.append(ModelResponse(parts=[tp])) case ThreadItemExitedReviewMode(review=review): assistant_display_parts.append(f"[Exited Review Mode: {review}]") - assistant_responses.append( - ModelResponse(parts=[TextPart(content=f"Exited review mode: {review}")]) - ) + tp = TextPart(content=f"Exited review mode: {review}") + assistant_responses.append(ModelResponse(parts=[tp])) case ThreadItemCollabAgentToolCall( tool=tool, @@ -696,22 +464,18 @@ def _turn_to_chat_messages(turn: Turn) -> list[ChatMessage[list[UserContent]]]: receiver_ids = ", ".join(receiver_thread_ids) display = f"[Collab Agent: {tool}] {receiver_ids} ({status})" assistant_display_parts.append(display) - collab_args: dict[str, Any] = { - "tool": tool, - "sender_thread_id": sender_thread_id, - } + collab_args: dict[str, Any] = {"tool": tool, "sender_thread_id": sender_thread_id} if receiver_thread_ids: collab_args["receiver_thread_ids"] = receiver_thread_ids if prompt: collab_args["prompt"] = prompt - collab_call = BuiltinToolCallPart( - tool_name="collab_agent", args=collab_args, tool_call_id=tc_id - ) - collab_ret = ToolReturnPart( - tool_name="collab_agent", content=f"Status: {status}", tool_call_id=tc_id + parts = get_tool_parts( + tool_name="collab_agent", + args=collab_args, + tc_id=tc_id, + output=f"Status: {status}", ) - assistant_responses.append(ModelResponse(parts=[collab_call])) - assistant_responses.append(ModelRequest(parts=[collab_ret])) + assistant_responses.append(ModelResponse(parts=parts)) case ThreadItemPlan() | ThreadItemDynamicToolCall() | ThreadItemContextCompaction(): pass case _ as unreachable: diff --git a/src/agentpool/agents/codex_agent/modes.py b/src/agentpool/agents/codex_agent/modes.py deleted file mode 100644 index 4e52657fe..000000000 --- a/src/agentpool/agents/codex_agent/modes.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Mode categories for CodexAgent.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from agentpool.agents.exceptions import UnknownModeError -from agentpool.agents.modes import ModeCategoryProtocol, ModeInfo - - -if TYPE_CHECKING: - from agentpool.agents.codex_agent.codex_agent import CodexAgent - - -# ============================================================================= -# Mode definitions (static data) -# ============================================================================= - -POLICY_MODES = [ - ModeInfo( - id="never", - name="Auto-Execute", - description="Execute tools without approval (default for programmatic use)", - category_id="mode", - ), - ModeInfo( - id="on-request", - name="On Request", - description="Ask for approval only when tool explicitly requests it", - category_id="mode", - ), - ModeInfo( - id="on-failure", - name="On Failure", - description="Ask for approval when a tool execution fails", - category_id="mode", - ), - ModeInfo( - id="untrusted", - name="Always Confirm", - description="Request approval before executing any tool", - category_id="mode", - ), -] - -SANDBOX_MODES = [ - ModeInfo( - id="read-only", - name="Read Only", - description="Sandbox with read-only file access", - category_id="sandbox", - ), - ModeInfo( - id="workspace-write", - name="Workspace Write", - description="Can write files within workspace directory", - category_id="sandbox", - ), - ModeInfo( - id="danger-full-access", - name="Full Access", - description="Full filesystem access (dangerous)", - category_id="sandbox", - ), - ModeInfo( - id="external-sandbox", - name="External Sandbox", - description="Use external sandbox environment", - category_id="sandbox", - ), -] - -EFFORT_MODES = [ - ModeInfo( - id="low", - name="Low Effort", - description="Fast responses with lighter reasoning", - category_id="thought_level", - ), - ModeInfo( - id="medium", - name="Medium Effort", - description="Balanced reasoning depth for everyday tasks", - category_id="thought_level", - ), - ModeInfo( - id="high", - name="High Effort", - description="Deep reasoning for complex problems", - category_id="thought_level", - ), - ModeInfo( - id="xhigh", - name="Extra High Effort", - description="Maximum reasoning depth for complex problems", - category_id="thought_level", - ), -] - - -# ============================================================================= -# Mode category implementations -# ============================================================================= - - -class CodexApprovalCategory(ModeCategoryProtocol["CodexAgent"]): - """Approval policy mode category for Codex.""" - - id: ClassVar[str] = "mode" - name: ClassVar[str] = "Tool Approval" - available_modes: ClassVar[list[ModeInfo]] = POLICY_MODES - category: ClassVar[str] = "mode" - - def get_current(self, agent: CodexAgent) -> str: - """Get current approval policy from agent.""" - return agent._approval_policy - - async def apply(self, agent: CodexAgent, mode_id: str) -> None: - """Apply approval policy mode.""" - valid_ids = {m.id for m in self.available_modes} - if mode_id not in valid_ids: - raise UnknownModeError(mode_id, list(valid_ids)) - agent._approval_policy = mode_id # type: ignore[assignment] - await agent.update_state(config_id=self.id, value_id=mode_id) - - -class CodexEffortCategory(ModeCategoryProtocol["CodexAgent"]): - """Reasoning effort mode category for Codex.""" - - id: ClassVar[str] = "thought_level" - name: ClassVar[str] = "Reasoning Effort" - available_modes: ClassVar[list[ModeInfo]] = EFFORT_MODES - category: ClassVar[str] = "thought_level" - - def get_current(self, agent: CodexAgent) -> str: - """Get current reasoning effort from agent.""" - return agent._current_effort or "medium" - - async def apply(self, agent: CodexAgent, mode_id: str) -> None: - """Apply reasoning effort mode.""" - if mode_id not in (valid_ids := {m.id for m in self.available_modes}): - raise UnknownModeError(mode_id, list(valid_ids)) - # Just store it - effort is passed per-turn, no restart needed - agent._current_effort = mode_id # type: ignore[assignment] - await agent.update_state(config_id=self.id, value_id=mode_id) - - -class CodexSandboxCategory(ModeCategoryProtocol["CodexAgent"]): - """Sandbox mode category for Codex.""" - - id: ClassVar[str] = "sandbox" - name: ClassVar[str] = "Sandbox Mode" - available_modes: ClassVar[list[ModeInfo]] = SANDBOX_MODES - category: ClassVar[str] = "other" - - def get_current(self, agent: CodexAgent) -> str: - """Get current sandbox mode from agent.""" - return agent._current_sandbox or "workspace-write" - - async def apply(self, agent: CodexAgent, mode_id: str) -> None: - """Apply sandbox mode.""" - valid_ids = {m.id for m in self.available_modes} - if mode_id not in valid_ids: - raise UnknownModeError(mode_id, list(valid_ids)) - agent._current_sandbox = mode_id # type: ignore[assignment] - await agent.update_state(config_id=self.id, value_id=mode_id) - - -class CodexModelCategory(ModeCategoryProtocol["CodexAgent"]): - """Model selection category for Codex.""" - - id: ClassVar[str] = "model" - name: ClassVar[str] = "Model" - available_modes: ClassVar[list[ModeInfo]] = [] # Populated dynamically - category: ClassVar[str] = "model" - - def get_current(self, agent: CodexAgent) -> str: - """Get current model from agent.""" - return agent._current_model or "" - - async def apply(self, agent: CodexAgent, mode_id: str) -> None: - """Apply model selection.""" - # Model validation is optional since models are dynamic - agent._current_model = mode_id - await agent.update_state(config_id=self.id, value_id=mode_id) diff --git a/src/agentpool/agents/codex_agent/static_info.py b/src/agentpool/agents/codex_agent/static_info.py index f86675f27..86747eca8 100644 --- a/src/agentpool/agents/codex_agent/static_info.py +++ b/src/agentpool/agents/codex_agent/static_info.py @@ -5,25 +5,25 @@ POLICY_MODES = [ ModeInfo( - id="never", + value="never", name="Auto-Execute", description="Execute tools without approval (default for programmatic use)", category_id="mode", ), ModeInfo( - id="on-request", + value="on-request", name="On Request", description="Ask for approval only when tool explicitly requests it", category_id="mode", ), ModeInfo( - id="on-failure", + value="on-failure", name="On Failure", description="Ask for approval when a tool execution fails", category_id="mode", ), ModeInfo( - id="untrusted", + value="untrusted", name="Always Confirm", description="Request approval before executing any tool", category_id="mode", @@ -33,25 +33,25 @@ SANDBOX_MODES = [ ModeInfo( - id="read-only", + value="read-only", name="Read Only", description="Sandbox with read-only file access", category_id="sandbox", ), ModeInfo( - id="workspace-write", + value="workspace-write", name="Workspace Write", description="Can write files within workspace directory", category_id="sandbox", ), ModeInfo( - id="danger-full-access", + value="danger-full-access", name="Full Access", description="Full filesystem access (dangerous)", category_id="sandbox", ), ModeInfo( - id="externalSandbox", + value="externalSandbox", name="External Sandbox", description="Use external sandbox environment", category_id="sandbox", @@ -61,25 +61,25 @@ EFFORT_MODES = [ ModeInfo( - id="low", + value="low", name="Low Effort", description="Fast responses with lighter reasoning", category_id="thought_level", ), ModeInfo( - id="medium", + value="medium", name="Medium Effort", description="Balanced reasoning depth for everyday tasks", category_id="thought_level", ), ModeInfo( - id="high", + value="high", name="High Effort", description="Deep reasoning for complex problems", category_id="thought_level", ), ModeInfo( - id="xhigh", + value="xhigh", name="Extra High Effort", description="Maximum reasoning depth for complex problems", category_id="thought_level", @@ -89,19 +89,19 @@ PERSONALITY_MODES = [ ModeInfo( - id="none", + value="none", name="None", description="No personality preset", category_id="personality", ), ModeInfo( - id="friendly", + value="friendly", name="Friendly", description="Warm and approachable tone", category_id="personality", ), ModeInfo( - id="pragmatic", + value="pragmatic", name="Pragmatic", description="Direct and efficient communication", category_id="personality", diff --git a/src/agentpool/agents/codex_agent/stream_adapter.py b/src/agentpool/agents/codex_agent/stream_adapter.py new file mode 100644 index 000000000..9521cf14a --- /dev/null +++ b/src/agentpool/agents/codex_agent/stream_adapter.py @@ -0,0 +1,224 @@ +"""Convert between Codex and AgentPool types. + +Provides converters for: +- Event conversion (Codex streaming events -> AgentPool events) +- MCP server configs (Native configs -> Codex types) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal + +from codexed.models import ( + ThreadTokenUsageUpdatedEvent, + TurnCompletedEvent, + TurnStartedEvent, +) +from pydantic_ai import PartEndEvent, TextPart, ThinkingPart + +from agentpool.agents.codex_agent.codex_converters import ( + _format_tool_result, + _thread_item_to_tool_call_part, +) +from agentpool.agents.events import ( + CompactionEvent, + PartDeltaEvent, + PartStartEvent, + PlanUpdateEvent, + TextContentItem, + ToolCallCompleteEvent, + ToolCallProgressEvent, + ToolCallStartEvent, +) +from agentpool.utils.streams.streamed_response import StreamedResponse +from agentpool.utils.time_utils import get_now +from agentpool.utils.todos import PlanEntry + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from datetime import datetime + + from codexed.models import CodexEvent, TokenUsageBreakdown, TurnStatus + + from agentpool.agents.events import RichAgentStreamEvent + + +@dataclass(kw_only=True) +class CodexStreamedResponse(StreamedResponse): + """Streamed codex response.""" + + stream: AsyncIterator[CodexEvent] + _timestamp: datetime = field(default_factory=get_now) + _model_name: str | None = None + _token_usage_data: TokenUsageBreakdown | None = None + _turn_status: TurnStatus | None = None + _current_turn_id: str | None = None + + async def _get_event_iterator(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: # noqa: PLR0915 + from codexed.models import ( + ItemAgentMessageDeltaNotification, + ItemCommandExecutionOutputDeltaNotification, + ItemCompletedEvent, + ItemFileChangeOutputDeltaNotification, + ItemMcpToolCallProgressNotification, + ItemReasoningTextDeltaNotification, + ItemStartedEvent, + ThreadCompactedMessage, + ThreadItemCommandExecution, + ThreadItemFileChange, + ThreadItemMcpToolCall, + TurnPlanUpdatedMessage, + ) + + # Accumulation state for streaming tool outputs + tool_outputs: dict[str, list[str]] = {} + active_part: Literal["text", "thinking", "tool"] | None = None + part_index = 0 + + async for event in self.stream: + match event: + case TurnStartedEvent(params=data): + self._current_turn_id = data.turn.id + case TurnCompletedEvent(params=data): + self._turn_status = data.turn.status + case ThreadTokenUsageUpdatedEvent(params=data): + self._token_usage_data = data.token_usage.last + # === Stateful: Accumulate command execution output === + case ItemCommandExecutionOutputDeltaNotification(params=data): + item_id = data.item_id + tool_outputs.setdefault(item_id, []).append(data.delta) + # Emit accumulated progress with replace semantics, wrapped in code block + output = "".join(tool_outputs[item_id]) + items = [TextContentItem(text=f"```\n{output}\n```")] + yield ToolCallProgressEvent( + tool_call_id=item_id, items=items, replace_content=True + ) + + # File change output delta - ignore the summary, we show diff from item/started + case ItemFileChangeOutputDeltaNotification(): + # The outputDelta is just "Success. Updated..." summary - not useful + # We already emitted the actual diff content in item/started + pass + + case ItemAgentMessageDeltaNotification(params=data): + if active_part != "text": + if active_part == "thinking": + yield PartEndEvent(index=part_index, part=ThinkingPart(content="")) + part_index += 1 + yield PartStartEvent.text(index=part_index, content="") + active_part = "text" + yield PartDeltaEvent.text(index=part_index, content=data.delta) + + case ItemReasoningTextDeltaNotification(params=data): + if active_part != "thinking": + if active_part == "text": + yield PartEndEvent(index=part_index, part=TextPart(content="")) + part_index += 1 + yield PartStartEvent.thinking(index=part_index, content="") + active_part = "thinking" + yield PartDeltaEvent.thinking(index=part_index, content=data.delta) + + case ItemStartedEvent(params=data): + # Close any open text/thinking part before a tool call + if active_part == "text": + yield PartEndEvent(index=part_index, part=TextPart(content="")) + part_index += 1 + elif active_part == "thinking": + yield PartEndEvent(index=part_index, part=ThinkingPart(content="")) + part_index += 1 + active_part = "tool" + if part := _thread_item_to_tool_call_part(data.item): + # Extract title based on tool type + match data.item: + case ThreadItemCommandExecution(command=command): + title = f"Execute: {command}" + case ThreadItemFileChange(changes=changes): + # Build title from file paths + paths = [c.path for c in changes[:3]] # First 3 paths + if len(changes) > 3: # noqa: PLR2004 + title = f"Edit: {', '.join(paths)} (+{len(changes) - 3} more)" + else: + title = f"Edit: {', '.join(paths)}" + case ThreadItemMcpToolCall(tool=tool): + title = f"Call {tool}" + case _: + title = f"Call {part.tool_name}" + + yield ToolCallStartEvent( + tool_call_id=part.tool_call_id, + tool_name=part.tool_name, + title=title, + raw_input=part.args_as_dict(), + ) + + # For file changes, immediately emit the diff as progress + if isinstance(data.item, ThreadItemFileChange): + diff_parts = [] + for change in data.item.changes: + diff_parts.append(f"{change.kind.kind.upper()}: {change.path}") + if change.diff: + diff_parts.append(change.diff) + if diff_parts: + items = [TextContentItem(text="\n".join(diff_parts))] + yield ToolCallProgressEvent( + tool_call_id=part.tool_call_id, items=items + ) + + # === Stateful: Tool/command completed - clean up accumulator === + case ItemCompletedEvent(params=data): + item = data.item + # Clean up accumulated output for this item + tool_outputs.pop(item.id, None) + if part := _thread_item_to_tool_call_part(item): + yield ToolCallCompleteEvent( + tool_name=part.tool_name, + tool_call_id=part.tool_call_id, + tool_input=part.args_as_dict(), + tool_result=await _format_tool_result(item), + agent_name="codex", # Will be overridden by agent + message_id=data.turn_id, + ) + + # === Stateless: MCP tool call progress === + case ItemMcpToolCallProgressNotification(params=data): + yield ToolCallProgressEvent(tool_call_id=data.item_id, message=data.message) + + # === Stateless: Thread compacted === + case ThreadCompactedMessage(params=data): + yield CompactionEvent(session_id=data.thread_id, phase="completed") + + # === Stateless: Turn plan updated === + case TurnPlanUpdatedMessage(params=data): + entries = [ + PlanEntry( + content=step.step, + priority="medium", # Codex doesn't provide priority + status="in_progress" if step.status == "inProgress" else step.status, + ) + for step in data.plan + ] + yield PlanUpdateEvent(entries=entries) + + # Ignore other events (token usage, turn started/completed, etc.) + case _: + pass + + # Emit end event for any open part + match active_part: + case "text": + yield PartEndEvent(index=part_index, part=TextPart(content="")) + case "thinking": + yield PartEndEvent(index=part_index, part=ThinkingPart(content="")) + + @property + def model_name(self) -> str: + """Get the model name of the response.""" + assert self._model_name + return self._model_name + + @property + def timestamp(self) -> datetime: + """Get the timestamp of the response.""" + return self._timestamp diff --git a/src/agentpool/agents/events/__init__.py b/src/agentpool/agents/events/__init__.py index 1b0ff19ac..d0fd4b40d 100644 --- a/src/agentpool/agents/events/__init__.py +++ b/src/agentpool/agents/events/__init__.py @@ -40,6 +40,7 @@ StreamProcessor, event_handler_processor, ) +from .reconstructor import MessageReconstructor __all__ = [ "BaseTTSEventHandler", @@ -51,6 +52,7 @@ "EdgeTTSEventHandler", "FileContentItem", "LocationContentItem", + "MessageReconstructor", "OpenAITTSEventHandler", "PartDeltaEvent", "PartStartEvent", diff --git a/src/agentpool/agents/events/events.py b/src/agentpool/agents/events/events.py index 23007158e..42d210816 100644 --- a/src/agentpool/agents/events/events.py +++ b/src/agentpool/agents/events/events.py @@ -28,6 +28,7 @@ TextPartDelta, ThinkingPart, ThinkingPartDelta, + ToolCallPart, ToolCallPartDelta, ) @@ -59,6 +60,13 @@ def thinking(cls, index: int, content: str) -> PartStartEvent: def text(cls, index: int, content: str) -> PartStartEvent: return cls(index=index, part=TextPart(content=content)) + @classmethod + def tool_call( + cls, index: int, tool_name: str, args: str | dict[str, Any], tool_call_id: str + ) -> PartStartEvent: + part = ToolCallPart(tool_name=tool_name, args=args, tool_call_id=tool_call_id) + return cls(index=index, part=part) + class PartDeltaEvent(PyAIPartDeltaEvent): """Part start event.""" @@ -216,6 +224,13 @@ class ToolCallStartEvent: """File locations affected by this tool call.""" raw_input: dict[str, Any] = field(default_factory=dict) """The raw input parameters sent to the tool.""" + field_meta: dict[str, Any] | None = None + """Protocol-level metadata passed through to ACP ``_meta``. + + Used for undocumented conventions like display-only terminal lifecycle + signals (``terminal_info``, ``terminal_output``, ``terminal_exit``). + See :mod:`acp.schema.field_meta` for known conventions. + """ event_kind: Literal["tool_call_start"] = "tool_call_start" """Event type identifier.""" @@ -262,6 +277,13 @@ class ToolCallProgressEvent: """The name of the tool being called.""" tool_input: dict[str, Any] | None = None """The input provided to the tool.""" + field_meta: dict[str, Any] | None = None + """Protocol-level metadata passed through to ACP ``_meta``. + + Used for undocumented conventions like display-only terminal lifecycle + signals (``terminal_info``, ``terminal_output``, ``terminal_exit``). + See :mod:`acp.schema.field_meta` for known conventions. + """ event_kind: Literal["tool_call_progress"] = "tool_call_progress" """Event type identifier.""" diff --git a/src/agentpool/agents/events/infer_info.py b/src/agentpool/agents/events/infer_info.py index a5d66c27c..a79d43242 100644 --- a/src/agentpool/agents/events/infer_info.py +++ b/src/agentpool/agents/events/infer_info.py @@ -57,13 +57,15 @@ def derive_rich_tool_info(name: str, input_data: ToolInput | dict[str, Any]) -> if tool_lower in ("read", "read_file"): path = input_data.get("file_path") or input_data.get("path", "") offset = input_data.get("offset") or input_data.get("line") + assert offset is None or isinstance(offset, int) suffix = "" if limit := input_data.get("limit"): - start = (offset or 0) + 1 # type: ignore[operator] - end = (offset or 0) + limit # type: ignore[operator] + assert isinstance(limit, int) + start = (offset or 0) + 1 + end = (offset or 0) + limit suffix = f" ({start}-{end})" elif offset: - suffix = f" (from line {offset + 1})" # type: ignore[operator] + suffix = f" (from line {offset + 1})" title = f"Read {path}{suffix}" if path else "Read File" locations = [LocationContentItem(path=path, line=offset or 0)] if path else [] # type: ignore[arg-type] return RichToolInfo(title=title, kind="read", locations=locations) @@ -72,36 +74,43 @@ def derive_rich_tool_info(name: str, input_data: ToolInput | dict[str, Any]) -> if tool_lower in ("write", "write_file"): path = input_data.get("file_path") or input_data.get("path", "") content = input_data.get("content", "") + assert isinstance(path, str) + assert isinstance(content, str) return RichToolInfo( title=f"Write {path}" if path else "Write File", kind="edit", - locations=[LocationContentItem(path=path)] if path else [], # type: ignore[arg-type] - content=[DiffContentItem(path=path, old_text=None, new_text=content)] if path else [], # type: ignore[arg-type] + locations=[LocationContentItem(path=path)] if path else [], + content=[DiffContentItem(path=path, old_text=None, new_text=content)] if path else [], ) # Edit operations if tool_lower in ("edit", "edit_file"): path = input_data.get("file_path") or input_data.get("path", "") old_string = input_data.get("old_string") or input_data.get("old_text", "") new_string = input_data.get("new_string") or input_data.get("new_text", "") + assert isinstance(path, str) + assert isinstance(old_string, str) + assert isinstance(new_string, str) return RichToolInfo( title=f"Edit {path}" if path else "Edit File", kind="edit", - locations=[LocationContentItem(path=path)] if path else [], # type: ignore[arg-type] - content=[DiffContentItem(path=path, old_text=old_string, new_text=new_string)] # type: ignore[arg-type] + locations=[LocationContentItem(path=path)] if path else [], + content=[DiffContentItem(path=path, old_text=old_string, new_text=new_string)] if path else [], ) # Delete operations if tool_lower in ("delete", "delete_path", "delete_file"): path = input_data.get("file_path") or input_data.get("path", "") - locations = [LocationContentItem(path=path)] if path else [] # type: ignore[arg-type] + assert isinstance(path, str) + locations = [LocationContentItem(path=path)] if path else [] title = f"Delete {path}" if path else "Delete" return RichToolInfo(title=title, kind="delete", locations=locations) # Bash/terminal operations if tool_lower in ("bash", "execute", "run_command", "execute_command", "execute_code"): command = input_data.get("command") or input_data.get("code", "") # Escape backticks in command - escaped_cmd = command.replace("`", "\\`") if command else "" # type: ignore[union-attr] + assert isinstance(command, str) + escaped_cmd = command.replace("`", "\\`") if command else "" title = f"`{escaped_cmd}`" if escaped_cmd else "Terminal" return RichToolInfo(title=title, kind="execute") # Search operations @@ -111,13 +120,15 @@ def derive_rich_tool_info(name: str, input_data: ToolInput | dict[str, Any]) -> title = f"Search for '{pattern}'" if pattern else "Search" if path: title += f" in {path}" - locations = [LocationContentItem(path=path)] if path else [] # type: ignore[arg-type] + assert isinstance(path, str) + locations = [LocationContentItem(path=path)] if path else [] return RichToolInfo(title=title, kind="search", locations=locations) # List directory if tool_lower in ("ls", "list", "list_directory"): path = input_data.get("path", ".") title = f"List {path}" if path != "." else "List current directory" - locations = [LocationContentItem(path=path)] if path else [] # type: ignore[arg-type] + assert isinstance(path, str) + locations = [LocationContentItem(path=path)] if path else [] return RichToolInfo(title=title, kind="search", locations=locations) # Web operations if tool_lower in ("webfetch", "web_fetch", "fetch"): @@ -129,17 +140,20 @@ def derive_rich_tool_info(name: str, input_data: ToolInput | dict[str, Any]) -> # Task/subagent operations if tool_lower == "task": description = input_data.get("description", "") - return RichToolInfo(title=description if description else "Task", kind="think") # type: ignore[arg-type] + assert isinstance(description, str) + return RichToolInfo(title=description if description else "Task", kind="think") # Notebook operations if tool_lower in ("notebookread", "notebook_read"): path = input_data.get("notebook_path", "") title = f"Read Notebook {path}" if path else "Read Notebook" - locations = [LocationContentItem(path=path)] if path else [] # type: ignore[arg-type] + assert isinstance(path, str) + locations = [LocationContentItem(path=path)] if path else [] return RichToolInfo(title=title, kind="read", locations=locations) if tool_lower in ("notebookedit", "notebook_edit"): path = input_data.get("notebook_path", "") title = f"Edit Notebook {path}" if path else "Edit Notebook" - locations = [LocationContentItem(path=path)] if path else [] # type: ignore[arg-type] + assert isinstance(path, str) + locations = [LocationContentItem(path=path)] if path else [] return RichToolInfo(title=title, kind="edit", locations=locations) # Default: use the tool name as title return RichToolInfo(title=actual_name, kind="other") diff --git a/src/agentpool/agents/events/processors.py b/src/agentpool/agents/events/processors.py index dc0a086ff..2ba268224 100644 --- a/src/agentpool/agents/events/processors.py +++ b/src/agentpool/agents/events/processors.py @@ -27,16 +27,11 @@ async def log_events(stream): from pydantic_ai import ( PartDeltaEvent, - TextPart, TextPartDelta, - ThinkingPart, ThinkingPartDelta, - ToolCallPart, ToolCallPartDelta, ) -from agentpool.agents.events import ToolCallStartEvent - if TYPE_CHECKING: from collections.abc import AsyncIterator, Coroutine @@ -157,20 +152,6 @@ async def process( return process -def event_to_part( - event: RichAgentStreamEvent[Any], -) -> TextPart | ThinkingPart | ToolCallPart | None: - match event: - case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)): - return TextPart(content=delta) - case PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)) if delta: - return ThinkingPart(content=delta) - case ToolCallStartEvent(tool_call_id=tc_id, tool_name=tc_name, raw_input=tc_input): - return ToolCallPart(tool_name=tc_name, args=tc_input, tool_call_id=tc_id) - case _: - return None - - async def batch_stream_deltas( # noqa: PLR0915 stream: AsyncIterator[RichAgentStreamEvent[Any]], ) -> AsyncIterator[RichAgentStreamEvent[Any]]: diff --git a/src/agentpool/agents/events/reconstructor.py b/src/agentpool/agents/events/reconstructor.py new file mode 100644 index 000000000..7b36b2138 --- /dev/null +++ b/src/agentpool/agents/events/reconstructor.py @@ -0,0 +1,276 @@ +"""Reconstruct pydantic-ai ModelRequest/ModelResponse sequences from event streams. + +This module provides a `MessageReconstructor` that observes `RichAgentStreamEvent`s +and builds the `list[ModelMessage]` sequence that a native pydantic-ai agent would +have produced. This eliminates per-agent duplication of response_parts / text_chunks / +model_messages tracking in ACP, Claude Code, Codex, and AG-UI agents. + +Usage:: + + reconstructor = MessageReconstructor(initial_prompts=prompts) + + async for event in raw_stream: + reconstructor.observe(event) + yield event + + # After stream ends: + reconstructor.flush() + messages = reconstructor.model_messages + text = reconstructor.text_content +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from pydantic_ai import ( + FunctionToolResultEvent, + ModelRequest, + ModelResponse, + PartDeltaEvent, + PartStartEvent, + TextPart, + TextPartDelta, + ThinkingPart, + ThinkingPartDelta, + ToolCallPart, + ToolCallPartDelta, + ToolReturnPart, + UserPromptPart, +) + +from agentpool.agents.events.events import ToolCallCompleteEvent, ToolCallStartEvent + + +if TYPE_CHECKING: + from pydantic_ai import FinishReason, ModelMessage, ModelResponsePart, UserContent + + from agentpool.agents.events.events import RichAgentStreamEvent + + +@dataclass +class MessageReconstructor: + """Reconstructs pydantic-ai ModelRequest/ModelResponse sequences from an event stream. + + Observes ``RichAgentStreamEvent`` instances and builds the ``ModelMessage`` list + that a native pydantic-ai agent run would have produced. Call :meth:`observe` for + every event in the stream, then :meth:`flush` once the stream is complete. + + Attributes: + model_messages: The accumulated message sequence. + text_content: All assistant text concatenated (convenience for ``ChatMessage.content``). + current_response_parts: Parts being accumulated for the current ``ModelResponse``. + """ + + model_messages: list[ModelMessage] = field(default_factory=list) + """Accumulated ModelRequest / ModelResponse sequence.""" + + current_response_parts: list[ModelResponsePart] = field(default_factory=list) + """Parts of the in-progress ModelResponse (flushed on tool result or end of stream).""" + + all_response_parts: list[ModelResponsePart] = field(default_factory=list) + """All response parts accumulated across the entire stream (never cleared by flush).""" + + _text_chunks: list[str] = field(default_factory=list) + """Raw text deltas for rebuilding ``text_content``.""" + + _thinking_chunks: list[str] = field(default_factory=list) + """Raw thinking deltas accumulated for the current thinking part.""" + + _model_name: str | None = field(default=None) + """Model name to attach to ModelResponse objects.""" + + _provider_name: str | None = field(default=None) + """Provider name to attach to ModelResponse objects.""" + + def __init__( + self, + *, + initial_prompts: list[UserContent] | None = None, + model_name: str | None = None, + provider_name: str | None = None, + ) -> None: + """Create a new reconstructor. + + Args: + initial_prompts: If provided, a ``ModelRequest`` with a + ``UserPromptPart`` is prepended to ``model_messages``. + model_name: Model name for ``ModelResponse`` objects. + provider_name: Provider name for ``ModelResponse`` objects. + """ + self.model_messages = [] + self.current_response_parts = [] + self.all_response_parts = [] + self._text_chunks = [] + self._thinking_chunks = [] + self._model_name = model_name + self._provider_name = provider_name + + if initial_prompts is not None: + initial_request = ModelRequest(parts=[UserPromptPart(content=initial_prompts)]) + self.model_messages.append(initial_request) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def text_content(self) -> str: + """All accumulated assistant text joined together.""" + return "".join(self._text_chunks) + + def observe(self, event: RichAgentStreamEvent[Any]) -> None: + """Observe a single stream event and update internal state. + + This should be called for every event yielded by the agent stream. + Events that are not relevant to message reconstruction are silently ignored. + """ + match event: + # --- Part starts (full parts arriving at once) --- + case PartStartEvent(part=TextPart(content=text)): + self.current_response_parts.append(TextPart(content=text)) + self._text_chunks.append(text) + + case PartStartEvent(part=ThinkingPart() | ToolCallPart() as part): + self.current_response_parts.append(part) + + # --- Deltas (streaming increments) --- + case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)): + self._text_chunks.append(delta) + # Merge into last TextPart or create a new one + self._merge_text_delta(delta) + + case PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)) if delta: + self._merge_thinking_delta(delta) + + case PartDeltaEvent(delta=ToolCallPartDelta(args_delta=args, tool_call_id=tc_id)): + self._merge_tool_call_delta(args, tc_id) + + # --- Tool call start (from external agents) --- + case ToolCallStartEvent(tool_call_id=tc_id, tool_name=name, raw_input=raw_input): + part = ToolCallPart(tool_name=name, args=raw_input, tool_call_id=tc_id) + self.current_response_parts.append(part) + + # --- Tool call complete → flush response, add return --- + case ToolCallCompleteEvent(tool_name=name, tool_call_id=tc_id, tool_result=result): + self._flush_response() + content = result if result is not None else "" + return_part = ToolReturnPart(tool_name=name, content=content, tool_call_id=tc_id) + self.model_messages.append(ModelRequest(parts=[return_part])) + + # --- pydantic-ai native tool result events --- + case FunctionToolResultEvent(result=ToolReturnPart() as return_part): + self._flush_response() + self.model_messages.append(ModelRequest(parts=[return_part])) + + case _: + pass # Ignore events not relevant to message reconstruction + + def flush(self, *, finish_reason: FinishReason | None = None) -> list[ModelMessage]: + """Flush remaining response parts into a final ``ModelResponse``. + + Should be called once after the stream ends. Returns the complete + ``model_messages`` list for convenience. + + Args: + finish_reason: Optional finish reason for the final ``ModelResponse``. + + Returns: + The complete list of ``ModelMessage`` objects. + """ + self._flush_response(finish_reason=finish_reason) + return self.model_messages + + def reset(self) -> None: + """Reset all state for reuse.""" + self.model_messages.clear() + self.current_response_parts.clear() + self.all_response_parts.clear() + self._text_chunks.clear() + self._thinking_chunks.clear() + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _flush_response(self, *, finish_reason: FinishReason | None = None) -> None: + """Flush ``current_response_parts`` into a ``ModelResponse`` if non-empty.""" + if not self.current_response_parts: + return + parts = list(self.current_response_parts) + response = ModelResponse( + parts=parts, + model_name=self._model_name, + provider_name=self._provider_name, + finish_reason=finish_reason, + ) + self.model_messages.append(response) + self.all_response_parts.extend(parts) + self.current_response_parts.clear() + + def _merge_text_delta(self, delta: str) -> None: + """Merge a text delta into the last TextPart, or create a new one.""" + for i in range(len(self.current_response_parts) - 1, -1, -1): + part = self.current_response_parts[i] + if isinstance(part, TextPart): + self.current_response_parts[i] = TextPart( + content=part.content + delta, + id=part.id, + provider_name=part.provider_name, + provider_details=part.provider_details, + ) + return + # No existing TextPart — create one + self.current_response_parts.append(TextPart(content=delta)) + + def _merge_thinking_delta(self, delta: str) -> None: + """Merge a thinking delta into the last ThinkingPart, or create a new one.""" + for i in range(len(self.current_response_parts) - 1, -1, -1): + part = self.current_response_parts[i] + if isinstance(part, ThinkingPart): + self.current_response_parts[i] = ThinkingPart( + content=part.content + delta, + id=part.id, + signature=part.signature, + provider_name=part.provider_name, + provider_details=part.provider_details, + ) + return + self.current_response_parts.append(ThinkingPart(content=delta)) + + def _merge_tool_call_delta( + self, args: str | dict[str, Any] | None, tool_call_id: str | None + ) -> None: + """Merge a tool call args delta into the matching ToolCallPart.""" + if args is None: + return + # Find matching tool call by ID (search backwards for efficiency) + if tool_call_id is not None: + for i in range(len(self.current_response_parts) - 1, -1, -1): + part = self.current_response_parts[i] + if isinstance(part, ToolCallPart) and part.tool_call_id == tool_call_id: + if isinstance(args, str) and isinstance(part.args, str): + self.current_response_parts[i] = ToolCallPart( + tool_name=part.tool_name, + args=part.args + args, + tool_call_id=part.tool_call_id, + id=part.id, + provider_name=part.provider_name, + provider_details=part.provider_details, + ) + return + # Fallback: update last ToolCallPart + for i in range(len(self.current_response_parts) - 1, -1, -1): + part = self.current_response_parts[i] + if isinstance(part, ToolCallPart): + if isinstance(args, str) and isinstance(part.args, str): + self.current_response_parts[i] = ToolCallPart( + tool_name=part.tool_name, + args=part.args + args, + tool_call_id=part.tool_call_id, + id=part.id, + provider_name=part.provider_name, + provider_details=part.provider_details, + ) + return diff --git a/src/agentpool/agents/exceptions.py b/src/agentpool/agents/exceptions.py index eec2e3a8d..58bec4163 100644 --- a/src/agentpool/agents/exceptions.py +++ b/src/agentpool/agents/exceptions.py @@ -30,8 +30,8 @@ def __init__(self, category_id: str, available: Sequence[str] | None = None): class UnknownModeError(ValueError): """Raised when an unknown mode is encountered.""" - def __init__(self, mode_id: str, available_modes: Sequence[str]): - msg = f"Unknown mode: {mode_id}. Available: {', '.join(available_modes)}" + def __init__(self, value: str | bool, available_modes: Sequence[str]): + msg = f"Unknown value: {value}. Available: {', '.join(available_modes)}" super().__init__(msg) diff --git a/src/agentpool/agents/interactions.py b/src/agentpool/agents/interactions.py index a5bc3b586..01346e51c 100644 --- a/src/agentpool/agents/interactions.py +++ b/src/agentpool/agents/interactions.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, Literal, cast, overload +from pydantic import create_model from schemez import Schema from agentpool.log import get_logger @@ -386,13 +387,9 @@ async def extract[T]( """ item_model = Schema.for_class_ctor(as_type) final_prompt = prompt or f"Extract {as_type.__name__} from: {text}" - - class Extraction(Schema): - instance: item_model # type: ignore[valid-type] - # explanation: str | None = None - + cls = create_model("Extraction", instance=item_model) # Use structured output via context manager - async with self._with_structured_output(Extraction) as structured_agent: + async with self._with_structured_output(cls) as structured_agent: result = await structured_agent.run(final_prompt) return as_type(**result.content.instance.model_dump()) diff --git a/src/agentpool/agents/modes.py b/src/agentpool/agents/modes.py index 67ee905c0..b4a9da210 100644 --- a/src/agentpool/agents/modes.py +++ b/src/agentpool/agents/modes.py @@ -23,6 +23,7 @@ "mode", # Session mode / permissions / approval policy "model", # Model selection "thought_level", # Thinking/reasoning effort level + "effort", # Reasoning effort level (low/medium/high/max) "personality", # Personality preset ] @@ -34,8 +35,8 @@ class ModeInfo: Represents one selectable option within a mode category. """ - id: str - """Unique identifier for this mode.""" + value: str | bool + """Value for this mode.""" name: str """Human-readable display name.""" @@ -58,7 +59,7 @@ class ConfigOptionChanged: config_id: str """ID of the config option that changed (e.g., 'permissions', 'model').""" - value_id: str + value_id: str | bool """New value ID for this config option.""" diff --git a/src/agentpool/agents/native_agent/agent.py b/src/agentpool/agents/native_agent/agent.py index e75a522a2..9e1e095f6 100644 --- a/src/agentpool/agents/native_agent/agent.py +++ b/src/agentpool/agents/native_agent/agent.py @@ -8,7 +8,7 @@ from datetime import timedelta from pathlib import Path import time -from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict, TypeVar, overload +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict, TypeVar, cast, overload from uuid import uuid4 import logfire @@ -35,11 +35,10 @@ from types import TracebackType from exxec import ExecutionEnvironment - from pydantic_ai import BaseToolCallPart, UsageLimits, UserContent + from pydantic_ai import BaseToolCallPart, ModelSettings, UsageLimits, UserContent from pydantic_ai.builtin_tools import AbstractBuiltinTool from pydantic_ai.models import Model from pydantic_ai.output import OutputSpec - from pydantic_ai.settings import ModelSettings from slashed import BaseCommand from tokonomics.model_discovery import ProviderType from tokonomics.model_discovery.model_info import ModelInfo @@ -211,7 +210,6 @@ def __init__( # noqa: PLR0915 from agentpool_commands.pool import CompactCommand from agentpool_config.session import MemoryConfig - self.model_settings = model_settings memory_cfg = ( session if isinstance(session, MemoryConfig) else MemoryConfig.from_value(session) ) @@ -262,6 +260,7 @@ def __init__( # noqa: PLR0915 session_config=memory_cfg, resources=resources, ) + self.model_settings = model_settings if isinstance(model, str): self._model, settings = self._resolve_model_string(model) if settings: @@ -531,7 +530,7 @@ def to_structured[NewOutputDataT]( Self (same instance, not a copy) """ self.log.debug("Setting result type", output_type=output_type) - self._output_type = to_type(output_type) # type: ignore[assignment] + self._output_type = to_type(output_type) # type: ignore[assignment] # ty:ignore[invalid-assignment] return self # type: ignore @property @@ -567,6 +566,7 @@ async def wrapped_tool(prompt: str) -> Any: await self.conversation.clear() history = None + old = [] if pass_message_history and parent: history = parent.conversation.get_history() old = self.conversation.get_history() @@ -577,12 +577,11 @@ async def wrapped_tool(prompt: str) -> Any: return result.data # Set the correct return annotation dynamically - wrapped_tool.__annotations__ = {"prompt": str, "return": self._output_type or Any} - normalized_name = self.name.replace("_", " ").title() - docstring = f"Get expert answer from specialized agent: {normalized_name}" + docstring = f"Get expert answer from specialized agent: {self.name}" if desc := (description or self.description): docstring = f"{docstring}\n\n{desc}" tool_name = name or f"ask_{self.name}" + wrapped_tool.__annotations__ = {"prompt": str, "return": self._output_type or Any} wrapped_tool.__doc__ = docstring wrapped_tool.__name__ = tool_name return FunctionTool.from_callable(wrapped_tool, source="agent") @@ -594,6 +593,7 @@ async def get_agentlet[AgentOutputType]( input_provider: InputProvider | None = None, ) -> PydanticAgent[TDeps, AgentOutputType]: """Create pydantic-ai agent from current state.""" + from agentpool.agents.native_agent.helpers import filter_builtin_tools from agentpool.agents.native_agent.tool_wrapping import wrap_tool tools = await self.tools.get_tools(state="enabled") @@ -603,9 +603,11 @@ async def get_agentlet[AgentOutputType]( model_, _settings = self._resolve_model_string(actual_model) else: model_ = actual_model - + # Filter builtin tools to those supported by the resolved model + builtin_tools = filter_builtin_tools(self._builtin_tools, model_) agent = PydanticAgent( name=self.name, + description=self.description, model=model_, model_settings=self.model_settings, instructions=self._formatted_system_prompt, @@ -614,15 +616,12 @@ async def get_agentlet[AgentOutputType]( output_retries=self._output_retries, deps_type=self.deps_type or NoneType, output_type=final_type, - builtin_tools=self._builtin_tools, + builtin_tools=builtin_tools, history_processors=self._history_processors or None, ) - context_for_tools = self.get_context(input_provider=input_provider) - for tool in tools: wrapped = wrap_tool(tool, context_for_tools, hooks=self._hook_manager) - prepare_fn = None if tool.schema_override: @@ -637,7 +636,9 @@ async def prepare_schema( return ToolDefinition( name=t.schema_override.get("name") or t.name, description=t.schema_override.get("description") or t.description, - parameters_json_schema=t.schema_override.get("parameters"), + parameters_json_schema=cast( + dict[str, Any], t.schema_override.get("parameters") + ), ) return prepare_schema @@ -648,7 +649,7 @@ async def prepare_schema( agent.tool(prepare=prepare_fn)(wrapped) else: agent.tool_plain(prepare=prepare_fn)(wrapped) - return agent # type: ignore[return-value] + return agent # type: ignore[return-value] # ty:ignore[invalid-return-type] async def _stream_events( self, @@ -697,12 +698,12 @@ async def _stream_events( case ModelRequestNode() | CallToolsNode(): async with ( node.stream(agent_run.ctx) as stream, - merge_queue_into_iterator(stream, self._event_queue) as merged, # type: ignore[arg-type] + merge_queue_into_iterator(stream, self._event_queue) as merged, # ty:ignore[invalid-argument-type] ): async for event in merged: if self._cancelled: break - yield event + yield event # ty:ignore[invalid-yield] if combined := process_tool_event( self.name, event, # ty: ignore[invalid-argument-type] @@ -717,14 +718,14 @@ async def _stream_events( # Build response message response_time = time.perf_counter() - start_time if self._cancelled: - partial_content = extract_text_from_messages( - agent_run.all_messages(), include_interruption_note=True - ) + msgs = agent_run.new_messages() + partial_content = extract_text_from_messages(msgs, include_interruption_note=True) response_msg = ChatMessage( content=partial_content, role="assistant", name=self.name, message_id=message_id, + usage=agent_run.usage(), session_id=self.session_id, parent_id=user_msg.message_id, response_time=response_time, @@ -838,7 +839,7 @@ async def temporary_state[T]( self._model = old_model self.model_settings = old_settings if output_type: - self.to_structured(old_type) + self.to_structured(old_type) # pyright: ignore[reportPossiblyUnboundVariable] async def get_available_models(self) -> list[ModelInfo] | None: """Get available models for this agent. @@ -873,23 +874,23 @@ async def get_modes(self) -> list[ModeCategory]: categories.append(model_category) return categories - async def _set_mode(self, mode_id: str, category_id: str) -> None: + async def _set_mode(self, mode_id: str | bool, category_id: str) -> None: """Handle permissions and model mode switching.""" - if category_id == "mode": - # Use native ToolConfirmationMode values directly - if mode_id not in VALID_MODES: - raise UnknownModeError(mode_id, VALID_MODES) - self.tool_confirmation_mode = mode_id # type: ignore[assignment] - await self.update_state(config_id="mode", value_id=mode_id) - - elif category_id == "model": - # Set the model directly - self._model, settings = self._resolve_model_string(mode_id) - if settings: - self.model_settings = settings - await self.update_state(config_id="model", value_id=mode_id) - else: - raise UnknownCategoryError(category_id, ["mode", "model"]) + from agentpool_config.nodes import ToolConfirmationMode + + match category_id: + case "mode": + if mode_id not in VALID_MODES: + raise UnknownModeError(mode_id, VALID_MODES) + self.tool_confirmation_mode = cast(ToolConfirmationMode, mode_id) + case "model": + assert isinstance(mode_id, str) + self._model, settings = self._resolve_model_string(mode_id) + if settings: + self.model_settings = settings + case _: + raise UnknownCategoryError(category_id, ["mode", "model"]) + await self.update_state(config_id=category_id, value_id=mode_id) async def list_sessions( self, diff --git a/src/agentpool/agents/native_agent/helpers.py b/src/agentpool/agents/native_agent/helpers.py index b0f17dd17..ec6577021 100644 --- a/src/agentpool/agents/native_agent/helpers.py +++ b/src/agentpool/agents/native_agent/helpers.py @@ -14,17 +14,47 @@ TextPart, ) +from agentpool import log from agentpool.agents.events import ToolCallCompleteEvent from agentpool.agents.modes import ModeCategory, ModeInfo from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict if TYPE_CHECKING: + from pydantic_ai import ModelMessage + from pydantic_ai.builtin_tools import AbstractBuiltinTool + from pydantic_ai.models import Model from tokonomics.model_discovery import ModelInfo from agentpool.agents.events import RichAgentStreamEvent from agentpool_config.nodes import ToolConfirmationMode +logger = log.get_logger(__name__) + + +def filter_builtin_tools( + tools: list[AbstractBuiltinTool], + model: Model, +) -> list[AbstractBuiltinTool]: + """Filter builtin tools to those supported by the model. + + Args: + tools: Builtin tools to filter + model: Resolved model instance + + Returns: + Filtered list containing only tools the model supports + """ + try: + supported = model.profile.supported_builtin_tools + except NotImplementedError: # some models (->FallbackModel) raise this when accessing profile + return tools + filtered = [t for t in tools if isinstance(t, tuple(supported))] + if len(filtered) != len(tools): + dropped = [type(t).__name__ for t in tools if not isinstance(t, tuple(supported))] + logger.info("Dropping unsupported builtin tools for %s: %s", model.model_name, dropped) + return filtered + def process_tool_event( agent_name: str, @@ -69,7 +99,10 @@ def process_tool_event( return None -def extract_text_from_messages(messages: list[Any], include_interruption_note: bool = False) -> str: +def extract_text_from_messages( + messages: list[ModelMessage], + include_interruption_note: bool = False, +) -> str: """Extract text content from pydantic-ai messages. Args: @@ -100,19 +133,19 @@ def get_permission_category(current_mode: ToolConfirmationMode) -> ModeCategory: name="Tool Confirmation", available_modes=[ ModeInfo( - id="always", + value="always", name="Always", description="Always require confirmation for all tools", category_id="mode", ), ModeInfo( - id="never", + value="never", name="Never", description="Never require confirmation (auto-approve all)", category_id="mode", ), ModeInfo( - id="per_tool", + value="per_tool", name="Per Tool", description="Require confirmation only for tools marked as needing it", category_id="mode", @@ -129,7 +162,7 @@ def get_model_category(current_model: str, models: list[ModelInfo]) -> ModeCateg name="Model", available_modes=[ ModeInfo( - id=m.id, + value=m.id, name=m.name or m.id, description=m.description or "", category_id="model", diff --git a/src/agentpool/agents/native_agent/stream_adapter.py b/src/agentpool/agents/native_agent/stream_adapter.py new file mode 100644 index 000000000..9edf53abc --- /dev/null +++ b/src/agentpool/agents/native_agent/stream_adapter.py @@ -0,0 +1,74 @@ +"""Stream adapter for converting pydantic-ai events to agentpool events. + +Iterates over an AgentRun's graph nodes, streaming events from each +ModelRequestNode/CallToolsNode and converting tool call/result pairs +into ToolCallCompleteEvents. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from pydantic_ai import CallToolsNode, ModelRequestNode +from pydantic_graph import End + +from agentpool.agents.native_agent.helpers import process_tool_event +from agentpool.utils.streams import merge_queue_into_iterator +from agentpool.utils.streams.streamed_response import StreamedResponse +from agentpool.utils.time_utils import get_now + + +if TYPE_CHECKING: + from asyncio import Queue + from collections.abc import AsyncIterator + from datetime import datetime + + from pydantic_ai import AgentRun, BaseToolCallPart + + from agentpool.agents.events import RichAgentStreamEvent + + +@dataclass(kw_only=True) +class PydanticAiStreamedResponse(StreamedResponse): + """Streamed pydantic-ai response.""" + + stream: AgentRun[Any, Any] + tool_metadata: dict[str, dict[str, Any]] + agent_name: str + message_id: str + _timestamp: datetime = field(default_factory=get_now) + _model_name: str | None = None + _event_queue: Queue[RichAgentStreamEvent[Any]] + + async def _get_event_iterator(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: + pending_tcs: dict[str, BaseToolCallPart] = {} + async for node in self.stream: + match node: + case End(): + break + case ModelRequestNode() | CallToolsNode(): + async with ( + node.stream(self.stream.ctx) as stream, + merge_queue_into_iterator(stream, self._event_queue) as merged, # ty:ignore[invalid-argument-type] + ): + async for event in merged: + yield event # ty:ignore[invalid-yield] + if combined := process_tool_event( + self.agent_name, + event, # ty: ignore[invalid-argument-type] + pending_tcs, + self.message_id, + ): + yield combined + + @property + def model_name(self) -> str: + """Get the model name of the response.""" + assert self._model_name + return self._model_name + + @property + def timestamp(self) -> datetime: + """Get the timestamp of the response.""" + return self._timestamp diff --git a/src/agentpool/agents/native_agent/tool_wrapping.py b/src/agentpool/agents/native_agent/tool_wrapping.py index 3444cc26c..c564b9b7a 100644 --- a/src/agentpool/agents/native_agent/tool_wrapping.py +++ b/src/agentpool/agents/native_agent/tool_wrapping.py @@ -139,9 +139,9 @@ async def _execute_with_hooks( if run_ctx_key or agent_ctx_key: # Tool has RunContext and/or AgentContext - async def wrapped( # pyright: ignore[reportRedeclaration] + async def wrapped( ctx: RunContext, *args: Any, **kwargs: Any - ) -> TReturn | None | ToolReturn: # pyright: ignore + ) -> TReturn | None | ToolReturn: confirm_ctx = replace( agent_ctx, tool_name=ctx.tool_name, @@ -207,7 +207,7 @@ async def wrapped(*args: Any, **kwargs: Any) -> TReturn | None | ToolReturn: # return None # Apply wraps first - wraps(fn)(wrapped) # pyright: ignore + wraps(fn)(wrapped) # Python 3.14: functools.wraps copies __annotate__ but not __annotations__. # Any subsequent assignment to __annotations__ destroys __annotate__ (PEP 649). # Restore from original to preserve deferred annotation evaluation. diff --git a/src/agentpool/agents/sys_prompts.py b/src/agentpool/agents/sys_prompts.py index f76e85af9..625cb3e46 100644 --- a/src/agentpool/agents/sys_prompts.py +++ b/src/agentpool/agents/sys_prompts.py @@ -47,7 +47,7 @@ def __init__( """Initialize prompt manager.""" match prompts: case list(): - self.prompts = prompts + self.prompts: list[AnyPromptType] = prompts # ty:ignore[invalid-assignment] case None: self.prompts = [] case _: @@ -88,7 +88,7 @@ async def add_by_reference(self, reference: str) -> None: try: content = await self.prompt_manager.get(reference) - self.prompts.append(content) # ty: ignore[invalid-argument-type] + self.prompts.append(content) except Exception as e: raise PromptResolutionError(f"failed to add prompt {reference!r}") from e @@ -122,7 +122,7 @@ async def add( version=version, variables=variables, ) - self.prompts.append(content) # ty: ignore[invalid-argument-type] + self.prompts.append(content) except Exception as e: ref = f"{provider + ':' if provider else ''}{identifier}" raise PromptResolutionError(f"failed to add prompt {ref!r}") from e diff --git a/src/agentpool/delegation/base_team.py b/src/agentpool/delegation/base_team.py index da9d63a61..415388dbb 100644 --- a/src/agentpool/delegation/base_team.py +++ b/src/agentpool/delegation/base_team.py @@ -22,10 +22,7 @@ from agentpool import Agent, AgentPool, Team from agentpool.agents.base_agent import BaseAgent - from agentpool.common_types import ( - ProcessorCallback, - PromptCompatible, - ) + from agentpool.common_types import ProcessorCallback, PromptCompatible from agentpool.delegation.teamrun import ExtendedTeamTalk, TeamRun from agentpool.messaging import ChatMessage, TeamResponse from agentpool.talk.stats import AggregatedTalkStats @@ -320,8 +317,7 @@ def get_context( shared_pool: AgentPool | None = None for agent in self.iter_agents(): - pool = agent.agent_pool - if pool: + if pool := agent.agent_pool: pool_id = id(pool) if pool_id not in pool_ids: pool_ids.add(pool_id) @@ -338,12 +334,7 @@ def get_context( if len(pool_ids) > 1: raise ValueError(f"Team members in {self.name} belong to different pools") - return TeamContext( - node=self, - pool=shared_pool, - input_provider=input_provider, - data=data, - ) + return TeamContext(node=self, pool=shared_pool, input_provider=input_provider, data=data) @abstractmethod async def execute( diff --git a/src/agentpool/delegation/pool.py b/src/agentpool/delegation/pool.py index 5cc4cfdb8..dfdb136dd 100644 --- a/src/agentpool/delegation/pool.py +++ b/src/agentpool/delegation/pool.py @@ -91,7 +91,7 @@ def __init__( # noqa: PLR0915 from agentpool.prompts.manager import PromptManager from agentpool.skills.manager import SkillsManager from agentpool.storage import StorageManager - from agentpool.utils.streams import FileOpsTracker + from agentpool.utils.file_ops_tracker import FileOpsTracker from agentpool.utils.todos import TodoTracker from agentpool.vfs_registry import VFSRegistry from agentpool_toolsets.builtin.debug import install_memory_handler @@ -448,7 +448,7 @@ def get_agent[TResult = str]( self, agent: AgentName | BaseAgent[Any, Any], *, - output_type: type[TResult] = str, # type: ignore[assignment] + output_type: type[TResult] = str, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> BaseAgent[TPoolDeps, TResult]: ... @overload @@ -457,7 +457,7 @@ def get_agent[TCustomDeps, TResult = str]( agent: AgentName | BaseAgent[Any, Any], *, deps_type: type[TCustomDeps], - output_type: type[TResult] = str, # type: ignore[assignment] + output_type: type[TResult] = str, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] ) -> BaseAgent[TCustomDeps, TResult]: ... def get_agent( diff --git a/src/agentpool/delegation/team.py b/src/agentpool/delegation/team.py index d40f7c2dd..597202c15 100644 --- a/src/agentpool/delegation/team.py +++ b/src/agentpool/delegation/team.py @@ -3,19 +3,21 @@ from __future__ import annotations import asyncio +from decimal import Decimal from time import perf_counter from typing import TYPE_CHECKING, Any from uuid import uuid4 from anyenv.async_run import as_generated import anyio +from pydantic_ai import RunUsage from agentpool.agents.base_agent import BaseAgent from agentpool.agents.events import SubAgentEvent from agentpool.delegation.base_team import BaseTeam from agentpool.delegation.teamrun import TeamRun from agentpool.log import get_logger -from agentpool.messaging import AgentResponse, ChatMessage, TeamResponse +from agentpool.messaging import AgentResponse, ChatMessage, TeamResponse, TokenCost from agentpool.messaging.processing import finalize_message, prepare_prompts from agentpool.utils.time_utils import get_now @@ -112,8 +114,7 @@ async def _run(node: MessageNode[TDeps, Any]) -> None: # If any failures occurred, raise error with details if failures: error_details = "\n".join(f"- {name}: {error}" for name, error in failures.items()) - error_msg = f"Some nodes failed to execute:\n{error_details}" - raise RuntimeError(error_msg) + raise RuntimeError(f"Some nodes failed to execute:\n{error_details}") finally: # Clean up any remaining tasks @@ -135,12 +136,21 @@ async def run( # Execute team logic result = await self.execute(*processed_prompts, **kwargs) message_id = str(uuid4()) # Always generate unique response ID + run_usage = RunUsage() + cost = TokenCost(total_cost=Decimal(0)) + for msg in result: + if msg.message: + run_usage.incr(msg.message.usage) + if msg.message.cost_info: + cost.incr(msg.message.cost_info) message = ChatMessage( content=[r.message.content for r in result if r.message], messages=[m for r in result if r.message for m in r.message.messages], role="assistant", name=self.name, message_id=message_id, + usage=run_usage, + cost_info=cost, session_id=user_msg.session_id, parent_id=user_msg.message_id, metadata={ @@ -154,15 +164,8 @@ async def run( if store_history: # Teams could implement their own history management here if needed pass - # Finalize and route message - return await finalize_message( - message, - user_msg, - self, - self.connections, - wait_for_connections, - ) + return await finalize_message(message, self, self.connections, wait_for_connections) async def run_stream( self, diff --git a/src/agentpool/delegation/teamrun.py b/src/agentpool/delegation/teamrun.py index 9028a9aad..cce732809 100644 --- a/src/agentpool/delegation/teamrun.py +++ b/src/agentpool/delegation/teamrun.py @@ -3,17 +3,19 @@ from __future__ import annotations from dataclasses import dataclass, field +from decimal import Decimal from itertools import pairwise from time import perf_counter -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any, overload from uuid import uuid4 import anyio +from pydantic_ai import RunUsage from agentpool.common_types import SupportsRunStream from agentpool.delegation.base_team import BaseTeam from agentpool.log import get_logger -from agentpool.messaging import AgentResponse, ChatMessage, TeamResponse +from agentpool.messaging import AgentResponse, ChatMessage, TeamResponse, TokenCost from agentpool.messaging.processing import finalize_message, prepare_prompts from agentpool.talk.talk import Talk, TeamTalk from agentpool.utils.time_utils import get_now @@ -33,8 +35,6 @@ logger = get_logger(__name__) -ResultMode = Literal["last", "concat"] - @dataclass(frozen=True, kw_only=True) class ExtendedTeamTalk(TeamTalk): @@ -151,12 +151,19 @@ async def run( # content = "\n".join(msg.format() for msg in all_messages) case _: raise ValueError(f"Invalid result mode: {self.result_mode}") - + run_usage = RunUsage() + cost = TokenCost(total_cost=Decimal(0)) + for chat_message in all_messages: + run_usage.incr(chat_message.usage) + if chat_message.cost_info: + cost.incr(chat_message.cost_info) message = ChatMessage( content=content, messages=[m for chat_message in all_messages for m in chat_message.messages], role="assistant", name=self.name, + usage=run_usage, + cost_info=cost, associated_messages=all_messages, message_id=message_id, session_id=user_msg.session_id, @@ -170,13 +177,7 @@ async def run( if store_history: pass # Teams could implement their own history management here if needed - return await finalize_message( # Finalize and route message - message, - user_msg, - self, - self.connections, - wait_for_connections, - ) + return await finalize_message(message, self, self.connections, wait_for_connections) async def execute( self, diff --git a/src/agentpool/docs/utils.py b/src/agentpool/docs/utils.py index 7000f3dec..75da9657c 100644 --- a/src/agentpool/docs/utils.py +++ b/src/agentpool/docs/utils.py @@ -1,6 +1,7 @@ """Helper functions for running examples in different environments.""" from __future__ import annotations +from collections.abc import Coroutine import asyncio import types @@ -22,7 +23,7 @@ def is_pyodide() -> bool: """Check if code is running in a Pyodide environment.""" try: - from js import Object # type: ignore[import-not-found] # noqa: F401 + from js import Object # type: ignore[import-not-found] # noqa: F401 # ty:ignore[unresolved-import] return True # noqa: TRY300 except ImportError: @@ -46,7 +47,7 @@ def get_config_path(module_path: str | None = None, filename: str = "config.yml" return Path(module_path).parent / filename -def run[T](coro: Awaitable[T]) -> T: +def run[T](coro: Coroutine[Any, Any, T]) -> T: """Run a coroutine in both normal Python and Pyodide environments.""" try: # Check if we're in an event loop @@ -55,7 +56,7 @@ def run[T](coro: Awaitable[T]) -> T: return asyncio.get_event_loop().run_until_complete(coro) except RuntimeError: # No running event loop, create one - return asyncio.run(coro) # type: ignore[arg-type] + return asyncio.run(coro) @dataclass @@ -97,18 +98,13 @@ def from_directory(cls, path: Path) -> Self | None: namespace: dict[str, str] = {} with init_file.open() as f: exec(f.read(), namespace) - # Get metadata with defaults - title = namespace.get("TITLE", path.name.replace("_", " ").title()) - icon = namespace.get("ICON", "octicon:code-16") - description = namespace.get("__doc__", "") - return cls( name=path.name, path=path, - title=title, - description=description, - icon=icon, + title=namespace.get("TITLE", path.name.replace("_", " ").title()), + description=namespace.get("__doc__", ""), + icon=namespace.get("ICON", "octicon:code-16"), ) @@ -152,12 +148,9 @@ def get_discriminator_values(union_type: Any) -> dict[str, type]: if origin not in (Union, types.UnionType): raise TypeError(f"Expected Union type, got: {union_type}") - # Get all types in the union - union_args = get_args(union_type) - # Extract discriminator values from each model result: dict[str, type] = {} - for model_cls in union_args: + for model_cls in get_args(union_type): if model_cls is type(None): continue @@ -226,11 +219,9 @@ def _strip_docstring_sections(description: str) -> str: Returns: Just the summary/description part without parameter documentation """ - lines = description.split("\n") result = [] in_section = False - - for line in lines: + for line in description.split("\n"): stripped = line.strip() # Check if we're entering a standard docstring section if stripped in ("Args:", "Arguments:", "Returns:", "Raises:", "Yields:", "Note:"): diff --git a/src/agentpool/mcp_server/client.py b/src/agentpool/mcp_server/client.py index d4c3da968..94e62c0e6 100644 --- a/src/agentpool/mcp_server/client.py +++ b/src/agentpool/mcp_server/client.py @@ -15,14 +15,13 @@ import logging from typing import TYPE_CHECKING, Any, Self, assert_never -import anyio from pydantic_ai import RunContext, ToolReturn from schemez import FunctionSchema from agentpool.agents.context import AgentContext from agentpool.log import get_logger from agentpool.mcp_server.constants import MCP_TO_LOGGING -from agentpool.mcp_server.helpers import extract_text_content, mcp_tool_to_fn_schema +from agentpool.mcp_server.helpers import extract_text_content, mcp_tool_to_input_schema from agentpool.mcp_server.message_handler import MCPMessageHandler from agentpool.tools.base import FunctionTool from agentpool.utils.signatures import create_modified_signature @@ -61,6 +60,7 @@ logger = get_logger(__name__) +MetaDict = dict[str, Any] class MCPClient: @@ -72,9 +72,9 @@ def __init__( sampling_callback: SamplingHandler[Any, Any] | None = None, message_handler: MessageHandlerT | MessageHandler | None = None, accessible_roots: list[str] | None = None, - tool_change_callback: Callable[[], Awaitable[None]] | None = None, - prompt_change_callback: Callable[[], Awaitable[None]] | None = None, - resource_change_callback: Callable[[], Awaitable[None]] | None = None, + tool_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None, + prompt_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None, + resource_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None, client_name: str | None = None, client_title: str | None = None, client_website_url: str | None = None, @@ -318,6 +318,7 @@ async def get_prompt( def convert_tool(self, tool: MCPTool) -> FunctionTool: """Create a properly typed callable from MCP tool schema.""" + from agentpool_config.tools import ToolHints async def tool_callable( ctx: RunContext, agent_ctx: AgentContext[Any], **kwargs: Any @@ -334,21 +335,23 @@ async def tool_callable( return await self.call_tool(tool.name, ctx, filtered_kwargs, agent_ctx) # Set proper signature and annotations with both RunContext and AgentContext - schema = mcp_tool_to_fn_schema(tool) - fn_schema = FunctionSchema.from_dict(schema) + schema = mcp_tool_to_input_schema(tool) + fn_schema = FunctionSchema.from_dict(schema, output_schema=tool.outputSchema) sig = fn_schema.to_python_signature() - tool_callable.__signature__ = create_modified_signature( # type: ignore[attr-defined] + tool_callable.__signature__ = create_modified_signature( # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] sig, inject={"ctx": RunContext, "agent_ctx": AgentContext} ) annotations = fn_schema.get_annotations() annotations["ctx"] = RunContext annotations["agent_ctx"] = AgentContext # Update return annotation to support multiple types - annotations["return"] = str | Any | ToolReturn # type: ignore[assignment] + if not tool.outputSchema: + annotations["return"] = str | Any | ToolReturn # type: ignore[assignment] # ty:ignore[invalid-assignment] tool_callable.__annotations__ = annotations tool_callable.__name__ = tool.name tool_callable.__doc__ = tool.description or "No description provided." - return FunctionTool.from_callable(tool_callable, source="mcp") + hints = ToolHints.from_mcp(tool.annotations) if tool.annotations else None + return FunctionTool.from_callable(tool_callable, source="mcp", hints=hints) async def call_tool( self, @@ -403,12 +406,14 @@ async def elicitation_handler[T]( # Prepare metadata to pass tool_call_id to the MCP server meta = None - if agent_ctx and agent_ctx.tool_call_id: - # Use the same key that tool_bridge expects: "claudecode/toolUseId" - # Ensure it's a string (handles both real values and mocks) - tool_call_id = str(agent_ctx.tool_call_id) if agent_ctx.tool_call_id else None - if tool_call_id: - meta = {"claudecode/toolUseId": tool_call_id} + # Use the same key that tool_bridge expects: "claudecode/toolUseId" + # Ensure it's a string (handles both real values and mocks) + if ( + agent_ctx + and (ctx_id := agent_ctx.tool_call_id) + and (tool_call_id := (str(ctx_id) if ctx_id else None)) + ): + meta = {"claudecode/toolUseId": tool_call_id} try: result = await self._client.call_tool( @@ -416,18 +421,13 @@ async def elicitation_handler[T]( ) content = await from_mcp_content(result.content) # Decision logic for return type - match (result.data is not None, bool(content)): - case (True, True): # Both structured data and rich content -> ToolReturn - return ToolReturn(return_value=result.data, content=content) - case (True, False): # Only structured data -> return directly - return result.data - case (False, True): # Only content -> ToolReturn with content - msg = "Tool executed successfully" - return ToolReturn(return_value=msg, content=content) - case (False, False): # Fallback to text extraction - return extract_text_content(result.content) - case _: # Handle unexpected cases - raise ValueError(f"Unexpected MCP content: {result.content}") # noqa: TRY301 + if result.data is not None and content: + return ToolReturn(return_value=result.data, content=content) + if result.data is not None: + return result.data + if content: + return ToolReturn(return_value="Tool executed successfully", content=content) + return extract_text_content(result.content) except Exception as e: raise RuntimeError(f"MCP tool call failed: {e}") from e finally: @@ -436,6 +436,8 @@ async def elicitation_handler[T]( if __name__ == "__main__": + import anyio + path = "/home/phil65/dev/oss/agentpool/tests/mcp_server/server.py" # path = Path(__file__).parent / "test_mcp_server.py" config = StdioMCPServerConfig(command="uv", args=["run", str(path)]) diff --git a/src/agentpool/mcp_server/conversions.py b/src/agentpool/mcp_server/conversions.py index 0ca55384d..aefc18065 100644 --- a/src/agentpool/mcp_server/conversions.py +++ b/src/agentpool/mcp_server/conversions.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Iterator, Sequence from fastmcp import Client from mcp.types import ( @@ -37,36 +37,34 @@ def to_mcp_messages( part: ModelRequestPart | ModelResponsePart, -) -> list[PromptMessage]: +) -> Iterator[PromptMessage]: """Convert internal PromptMessage to MCP PromptMessage.""" from mcp.types import AudioContent, ImageContent, PromptMessage, TextContent - messages = [] match part: case UserPromptPart(content=str() as c): content = TextContent(type="text", text=c) - messages.append(PromptMessage(role="user", content=content)) + yield PromptMessage(role="user", content=content) case UserPromptPart(content=content_items): for item in content_items: match item: case BinaryContent(data=data, media_type=media_type) if item.is_audio: encoded = base64.b64encode(data).decode() audio = AudioContent(type="audio", data=encoded, mimeType=media_type) - messages.append(PromptMessage(role="user", content=audio)) + yield PromptMessage(role="user", content=audio) case BinaryContent(data=data, media_type=media_type) if item.is_image: encoded = base64.b64encode(data).decode() image = ImageContent(type="image", data=encoded, mimeType=media_type) - messages.append(PromptMessage(role="user", content=image)) + yield PromptMessage(role="user", content=image) case FileUrl(url=url): content = TextContent(type="text", text=url) - messages.append(PromptMessage(role="user", content=content)) + yield PromptMessage(role="user", content=content) case SystemPromptPart(content=msg): - messages.append(PromptMessage(role="user", content=TextContent(type="text", text=msg))) + yield PromptMessage(role="user", content=TextContent(type="text", text=msg)) case TextPart(content=msg): text_content = TextContent(type="text", text=msg) - messages.append(PromptMessage(role="assistant", content=text_content)) - return messages + yield PromptMessage(role="assistant", content=text_content) def sampling_messages_to_user_content(msgs: list[SamplingMessage]) -> list[UserContent]: @@ -89,12 +87,12 @@ def content_block_to_user_content(content: SamplingMessageContentBlock) -> UserC match content: case types.TextContent(text=text): return text - case types.ImageContent(data=data, mimeType=mime_type): - binary_data = base64.b64decode(data) - return BinaryImage(data=binary_data, media_type=mime_type) - case types.AudioContent(data=data, mimeType=mime_type): - binary_data = base64.b64decode(data) - return BinaryContent(data=binary_data, media_type=mime_type) + case ( + types.ImageContent(data=data, mimeType=mime) + | types.AudioContent(data=data, mimeType=mime) + ): + bin_content = BinaryContent(data=base64.b64decode(data), media_type=mime) + return BinaryContent.narrow_type(bin_content) case types.ToolUseContent() | types.ToolResultContent(): return None case _ as unreachable: diff --git a/src/agentpool/mcp_server/helpers.py b/src/agentpool/mcp_server/helpers.py index 88d3812b3..19822912f 100644 --- a/src/agentpool/mcp_server/helpers.py +++ b/src/agentpool/mcp_server/helpers.py @@ -18,8 +18,8 @@ logger = get_logger(__name__) -def mcp_tool_to_fn_schema(tool: MCPTool) -> dict[str, Any]: - """Convert MCP tool to OpenAI function schema format.""" +def mcp_tool_to_input_schema(tool: MCPTool) -> dict[str, Any]: + """Convert MCP tool inputSchema to OpenAI function schema format.""" return { "name": tool.name, "description": tool.description or "", diff --git a/src/agentpool/mcp_server/message_handler.py b/src/agentpool/mcp_server/message_handler.py index b4374e7fc..faf5e0f61 100644 --- a/src/agentpool/mcp_server/message_handler.py +++ b/src/agentpool/mcp_server/message_handler.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, assert_never from agentpool.log import get_logger @@ -11,13 +11,15 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable + from mcp import types from mcp.shared.session import RequestResponder - import mcp.types from agentpool.mcp_server import MCPClient logger = get_logger(__name__) +MetaDict = dict[str, Any] + @dataclass class MCPMessageHandler: @@ -25,22 +27,22 @@ class MCPMessageHandler: client: MCPClient """The MCP client instance.""" - tool_change_callback: Callable[[], Awaitable[None]] | None = None + tool_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None """Tool change callback.""" - prompt_change_callback: Callable[[], Awaitable[None]] | None = None + prompt_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None """Prompt change callback.""" - resource_change_callback: Callable[[], Awaitable[None]] | None = None + resource_change_callback: Callable[[MetaDict], Awaitable[None]] | None = None """Resource change callback.""" async def __call__( self, - message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] - | mcp.types.ServerNotification + message: RequestResponder[types.ServerRequest, types.ClientResult] + | types.ServerNotification | Exception, ) -> None: """Handle FastMCP messages by dispatching to appropriate handlers.""" + from mcp import types from mcp.shared.session import RequestResponder - import mcp.types await self.on_message(message) match message: @@ -50,93 +52,115 @@ async def __call__( # Handle specific requests root = responder.request.root match root: - case mcp.types.PingRequest(): + case types.PingRequest(): await self.on_ping(root) - case mcp.types.ListRootsRequest(): + case types.ListRootsRequest(): await self.on_list_roots(root) - case mcp.types.CreateMessageRequest(): + case types.CreateMessageRequest(): await self.on_create_message(root) - - case mcp.types.ServerNotification() as notification: + case ( + types.GetTaskRequest() + | types.ListTasksRequest() + | types.ElicitRequest() + | types.GetTaskPayloadRequest() + | types.CancelTaskRequest() + ): + pass + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] + + case types.ServerNotification() as notification: await self.on_notification(notification) root = notification.root match root: - case mcp.types.CancelledNotification(): + case types.CancelledNotification(): await self.on_cancelled(root) - case mcp.types.ProgressNotification(): + case types.ProgressNotification(): await self.on_progress(root) - case mcp.types.LoggingMessageNotification(): + case types.LoggingMessageNotification(): await self.on_logging_message(root) - case mcp.types.ToolListChangedNotification(): + case types.ToolListChangedNotification(): await self.on_tool_list_changed(root) - case mcp.types.ResourceListChangedNotification(): + case types.ResourceListChangedNotification(): await self.on_resource_list_changed(root) - case mcp.types.PromptListChangedNotification(): + case types.PromptListChangedNotification(): await self.on_prompt_list_changed(root) - case mcp.types.ResourceUpdatedNotification(): + case types.ResourceUpdatedNotification(): await self.on_resource_updated(root) - case mcp.types.ElicitCompleteNotification(): + case types.ElicitCompleteNotification(): await self.on_elicit_complete(root) + case types.TaskStatusNotification(): + await self.on_task_status(root) + case _ as unreachable: + assert_never(unreachable) # ty:ignore[type-assertion-failure] case Exception(): await self.on_exception(message) async def on_message( self, - message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] - | mcp.types.ServerNotification + message: RequestResponder[types.ServerRequest, types.ClientResult] + | types.ServerNotification | Exception, ) -> None: """Handle generic messages.""" async def on_request( - self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] + self, message: RequestResponder[types.ServerRequest, types.ClientResult] ) -> None: """Handle requests.""" - async def on_notification(self, message: mcp.types.ServerNotification) -> None: + async def on_notification(self, message: types.ServerNotification) -> None: """Handle server notifications.""" - async def on_tool_list_changed(self, message: mcp.types.ToolListChangedNotification) -> None: + async def on_tool_list_changed(self, message: types.ToolListChangedNotification) -> None: """Handle tool list changes.""" logger.info("MCP tool list changed", message=message) # Call the tool change callback if provided if self.tool_change_callback: - await self.tool_change_callback() + meta = message.params.meta if message.params else None + dct = meta.model_dump() if meta else {} + await self.tool_change_callback(dct) async def on_resource_list_changed( - self, message: mcp.types.ResourceListChangedNotification + self, message: types.ResourceListChangedNotification ) -> None: """Handle resource list changes.""" logger.info("MCP resource list changed", message=message) # Call the resource change callback if provided if self.resource_change_callback: - await self.resource_change_callback() + meta = message.params.meta if message.params else None + dct = meta.model_dump() if meta else {} + await self.resource_change_callback(dct) - async def on_resource_updated(self, message: mcp.types.ResourceUpdatedNotification) -> None: + async def on_resource_updated(self, message: types.ResourceUpdatedNotification) -> None: """Handle resource updates.""" # ResourceUpdatedNotification has uri directly, not in params logger.info("MCP resource updated", uri=getattr(message, "uri", "unknown")) - async def on_progress(self, message: mcp.types.ProgressNotification) -> None: + async def on_progress(self, message: types.ProgressNotification) -> None: """Handle progress notifications with proper context.""" # Note: Progress notifications from MCP servers are now handled per-tool-call # with the contextual progress handler, so global notifications are ignored - async def on_prompt_list_changed( - self, message: mcp.types.PromptListChangedNotification - ) -> None: + async def on_prompt_list_changed(self, message: types.PromptListChangedNotification) -> None: """Handle prompt list changes.""" logger.info("MCP prompt list changed", message=message) # Call the prompt change callback if provided if self.prompt_change_callback: - await self.prompt_change_callback() + meta = message.params.meta if message.params else None + dct = meta.model_dump() if meta else {} + await self.prompt_change_callback(dct) - async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None: + async def on_cancelled(self, message: types.CancelledNotification) -> None: """Handle cancelled operations.""" logger.info("MCP operation cancelled", message=message) - async def on_logging_message(self, message: mcp.types.LoggingMessageNotification) -> None: + async def on_task_status(self, message: types.TaskStatusNotification) -> None: + """Handle task status notifications.""" + logger.info("MCP task status", message=message) + + async def on_logging_message(self, message: types.LoggingMessageNotification) -> None: """Handle server log messages.""" # This is handled by _log_handler, but keep for completeness @@ -144,21 +168,18 @@ async def on_exception(self, message: Exception) -> None: """Handle exceptions.""" logger.error("MCP client exception", error=message) - async def on_ping(self, message: mcp.types.PingRequest) -> None: + async def on_ping(self, message: types.PingRequest) -> None: """Handle ping requests.""" - async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None: + async def on_list_roots(self, message: types.ListRootsRequest) -> None: """Handle list roots requests.""" - async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None: + async def on_create_message(self, message: types.CreateMessageRequest) -> None: """Handle create message requests.""" - async def on_elicit_complete(self, message: mcp.types.ElicitCompleteNotification) -> None: + async def on_elicit_complete(self, message: types.ElicitCompleteNotification) -> None: """Handle elicitation completion notifications. Sent by servers when a URL mode elicitation completes out-of-band. """ - logger.info( - "MCP elicitation completed", - elicitation_id=message.params.elicitationId, - ) + logger.info("MCP elicitation completed", elicitation_id=message.params.elicitationId) diff --git a/src/agentpool/mcp_server/registries/official_registry_client.py b/src/agentpool/mcp_server/registries/official_registry_client.py index f6b86a44e..499bc41e7 100644 --- a/src/agentpool/mcp_server/registries/official_registry_client.py +++ b/src/agentpool/mcp_server/registries/official_registry_client.py @@ -296,7 +296,7 @@ async def get_server(self, server_id: str) -> RegistryServer: response = await self.client.get(f"{self.base_url}/v0/servers") response.raise_for_status() data = response.json() - response_data = RegistryListResponse(**data) + response_data = RegistryListResponse.model_validate(data) # Find server by name target_wrapper = None for wrapper in response_data.servers: @@ -322,7 +322,7 @@ async def get_server(self, server_id: str) -> RegistryServer: except (httpx.HTTPError, ValueError, KeyError) as e: raise MCPRegistryError(f"Failed to get server details: {e}") from e else: - server = RegistryServer(**server_data) + server = RegistryServer.model_validate(server_data) ts = time.time() self._cache_servers[cache_key] = GetServerCacheEntry(server=server, timestamp=ts) log.info("Successfully fetched server details for %s", server_id) diff --git a/src/agentpool/mcp_server/tool_bridge.py b/src/agentpool/mcp_server/tool_bridge.py index 7b1c09af0..ec59ec582 100644 --- a/src/agentpool/mcp_server/tool_bridge.py +++ b/src/agentpool/mcp_server/tool_bridge.py @@ -16,18 +16,15 @@ from dataclasses import dataclass, field, replace import inspect import time -from typing import TYPE_CHECKING, Any, Self, get_args, get_origin +from typing import TYPE_CHECKING, Any, Self, cast, get_args, get_origin from uuid import uuid4 import anyio -from pydantic import BaseModel -from pydantic_ai import RunContext +from pydantic_ai import RunContext, RunUsage from pydantic_ai.models.test import TestModel -from pydantic_ai.usage import RunUsage from agentpool.agents import Agent from agentpool.log import get_logger -from agentpool.resource_providers import ResourceChangeEvent from agentpool.utils.signatures import filter_schema_params, get_params_matching_predicate @@ -35,15 +32,15 @@ from collections.abc import AsyncIterator, Callable, Sequence from fastmcp import Context, FastMCP - from fastmcp.tools.tool import ToolResult as FastMCPToolResult - from pydantic_ai.messages import UserContent + from fastmcp.tools import ToolResult as FastMCPToolResult + from pydantic_ai import UserContent from uvicorn import Server from agentpool.agents import AgentContext from agentpool.agents.base_agent import BaseAgent from agentpool.agents.prompt_injection import PromptInjectionManager + from agentpool.resource_providers import ResourceChangeEvent from agentpool.tools.base import Tool -_ = ResourceChangeEvent # Used at runtime in method signature logger = get_logger(__name__) @@ -64,8 +61,7 @@ def _is_annotation_of_type(annotation: Any, type_name: str) -> bool: if isinstance(annotation, type) and annotation.__name__ == type_name: return True # Check generic origin (e.g., SomeType[T]) - origin = get_origin(annotation) - if origin is not None: + if origin := get_origin(annotation): if isinstance(origin, type) and origin.__name__ == type_name: return True # Handle Union types (e.g., SomeType | None) @@ -120,37 +116,6 @@ def _create_stub_run_context( ) -def _convert_to_tool_result(result: Any) -> FastMCPToolResult: - """Convert a tool's return value to a FastMCP ToolResult. - - Handles different result types appropriately: - - FastMCP ToolResult: Pass through unchanged - - AgentPool ToolResult: Convert to FastMCP format - - dict: Use as structured_content (enables programmatic access by clients) - - Pydantic models: Serialize to dict for structured_content - - Other types: Pass to ToolResult(content=...) which handles conversion internally - """ - from fastmcp.tools.tool import ToolResult as FastMCPToolResult - - from agentpool.tools.base import ToolResult as AgentPoolToolResult - - match result: - case FastMCPToolResult(): - return result - case AgentPoolToolResult(): - return FastMCPToolResult( - content=result.content, - structured_content=result.structured_content, - meta=result.metadata, - ) - case dict(): - return FastMCPToolResult(structured_content=result) - case BaseModel(): - return FastMCPToolResult(structured_content=result.model_dump(mode="json")) - case _: - return FastMCPToolResult(content=result if result is not None else "") - - def _append_injection_to_result(result: Any, injection: str) -> Any: """Append an injected message to a tool result. @@ -409,13 +374,12 @@ def __init__(self, tool: Tool, bridge: ToolManagerBridge) -> None: run_context_params = _get_context_param_names(fn, "RunContext") all_context_params = context_params | run_context_params filtered_schema = filter_schema_params(input_schema, all_context_params) - desc = tool.description or "No description" super().__init__( name=tool.name, - description=desc, - parameters=filtered_schema, + description=tool.description or "No description", + parameters=cast(dict[str, Any], filtered_schema), annotations=tool.get_mcp_tool_annotations(), - # output_schema=..., + # output_schema=tool.output_schema, ) # Set these AFTER super().__init__() to avoid being overwritten self._tool = tool @@ -424,6 +388,7 @@ def __init__(self, tool: Tool, bridge: ToolManagerBridge) -> None: async def run(self, arguments: dict[str, Any]) -> FastMCPToolResult: """Execute the wrapped tool with context bridging.""" from fastmcp.server.dependencies import get_context + from fastmcp.tools import ToolResult as FastMCPToolResult from agentpool.tools.base import ToolResult as AgentPoolToolResult @@ -458,14 +423,17 @@ async def run(self, arguments: dict[str, Any]) -> FastMCPToolResult: if isinstance(result, AgentPoolToolResult) and result.metadata: logger.info("Storing tool result metadata", tool_call_id=tc_id) self._bridge.tool_metadata[tc_id] = result.metadata - # Consume pending injection and append to result - if self._bridge.injection_manager and ( - injection := await self._bridge.injection_manager.consume() - ): + if (m := self._bridge.injection_manager) and (injection := await m.consume()): result = _append_injection_to_result(result, injection) - - return _convert_to_tool_result(result) + # Convert AgentPool ToolResult to FastMCP ToolResult + if isinstance(result, AgentPoolToolResult): + return FastMCPToolResult( + content=result.content, + structured_content=result.structured_content, + meta=result.metadata, + ) + return self.convert_result(result) # Create a custom FastMCP Tool that wraps our tool bridge_tool = _BridgeTool(tool=tool, bridge=self) @@ -526,16 +494,12 @@ async def invoke_tool_with_context( fn = tool.get_callable() # Inject AgentContext parameters context_param_names = _get_context_param_names(fn, "AgentContext") - for param_name in context_param_names: - if param_name not in kwargs: - kwargs[param_name] = ctx + kwargs |= {name: ctx for name in context_param_names if name not in kwargs} # Inject RunContext parameters (as stub since we're outside pydantic-ai) run_context_param_names = _get_context_param_names(fn, "RunContext") if run_context_param_names: stub_run_ctx = _create_stub_run_context(ctx, prompt=self._current_prompt) - for param_name in run_context_param_names: - if param_name not in kwargs: - kwargs[param_name] = stub_run_ctx + kwargs |= {name: stub_run_ctx for name in run_context_param_names if name not in kwargs} start_time = time.perf_counter() result = fn(**kwargs) diff --git a/src/agentpool/messaging/chat_filesystem.py b/src/agentpool/messaging/chat_filesystem.py index 71b4fab84..c0fefff68 100644 --- a/src/agentpool/messaging/chat_filesystem.py +++ b/src/agentpool/messaging/chat_filesystem.py @@ -6,17 +6,69 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Required, overload import anyenv -from fsspec.asyn import AsyncFileSystem +from upathtools.filesystems.base import BaseAsyncFileFileSystem, BaseUPath, FileInfo if TYPE_CHECKING: from agentpool.messaging.message_container import ChatMessageList -class ChatMessageFileSystem(AsyncFileSystem): # type: ignore[misc] +class ChatMessageInfo(FileInfo): + """Info dict for ACP filesystem paths.""" + + size: Required[int] + + +class ChatMessagePath(BaseUPath[ChatMessageInfo]): + """Path for ACP filesystem.""" + + __slots__ = () + + +def _get_file_entries(messages: ChatMessageList) -> dict[str, bytes]: + """Generate file entries from current messages.""" + entries: dict[str, bytes] = {} + + for msg in messages: + timestamp = msg.timestamp.strftime("%Y%m%d_%H%M%S_%f") + base_name = f"{timestamp}_{msg.role}_{msg.message_id}" + + # Content file + content_path = f"/messages/{base_name}.txt" + entries[content_path] = str(msg.content).encode("utf-8") + + # Metadata file + metadata = { + "message_id": msg.message_id, + "role": msg.role, + "timestamp": msg.timestamp.isoformat(), + "parent_id": msg.parent_id, + "model_name": msg.model_name, + "tokens": msg.usage.total_tokens if msg.usage else None, + "cost": float(msg.cost_info.total_cost) if msg.cost_info else None, + } + metadata_path = f"/messages/{base_name}.json" + entries[metadata_path] = anyenv.dump_json(metadata, indent=True).encode() + + # Summary file + summary = { + "total_messages": len(messages), + "total_tokens": messages.get_history_tokens(), + "total_cost": messages.get_total_cost(), + "roles": { + "user": len([m for m in messages if m.role == "user"]), + "assistant": len([m for m in messages if m.role == "assistant"]), + }, + } + entries["/summary.json"] = anyenv.dump_json(summary, indent=True).encode("utf-8") + + return entries + + +class ChatMessageFileSystem(BaseAsyncFileFileSystem[ChatMessagePath, ChatMessageInfo]): """Read-only filesystem exposing ChatMessages as files. Structure: @@ -48,45 +100,6 @@ def __init__( super().__init__(**kwargs) self._messages = messages - def _get_file_entries(self) -> dict[str, bytes]: - """Generate file entries from current messages.""" - entries: dict[str, bytes] = {} - - for msg in self._messages: - timestamp = msg.timestamp.strftime("%Y%m%d_%H%M%S_%f") - base_name = f"{timestamp}_{msg.role}_{msg.message_id}" - - # Content file - content_path = f"/messages/{base_name}.txt" - entries[content_path] = str(msg.content).encode("utf-8") - - # Metadata file - metadata = { - "message_id": msg.message_id, - "role": msg.role, - "timestamp": msg.timestamp.isoformat(), - "parent_id": msg.parent_id, - "model_name": msg.model_name, - "tokens": msg.usage.total_tokens if msg.usage else None, - "cost": float(msg.cost_info.total_cost) if msg.cost_info else None, - } - metadata_path = f"/messages/{base_name}.json" - entries[metadata_path] = anyenv.dump_json(metadata, indent=True).encode() - - # Summary file - summary = { - "total_messages": len(self._messages), - "total_tokens": self._messages.get_history_tokens(), - "total_cost": self._messages.get_total_cost(), - "roles": { - "user": len([m for m in self._messages if m.role == "user"]), - "assistant": len([m for m in self._messages if m.role == "assistant"]), - }, - } - entries["/summary.json"] = anyenv.dump_json(summary, indent=True).encode("utf-8") - - return entries - def _get_dirs(self) -> set[str]: """Get all virtual directories.""" return {"/", "/messages", "/by_role", "/by_role/user", "/by_role/assistant"} @@ -97,45 +110,59 @@ def _normalize_path(self, path: str) -> str: path = "/" + path return path.rstrip("/") or "/" + @overload + async def _ls( + self, path: str, detail: Literal[True] = ..., **kwargs: Any + ) -> list[ChatMessageInfo]: ... + + @overload + async def _ls(self, path: str, detail: Literal[False], **kwargs: Any) -> list[str]: ... + async def _ls( self, path: str, detail: bool = True, **kwargs: Any, - ) -> list[dict[str, Any]] | list[str]: + ) -> list[ChatMessageInfo] | list[str]: """List directory contents.""" path = self._normalize_path(path) - file_entries = self._get_file_entries() + file_entries = _get_file_entries(self._messages) - entries: list[dict[str, Any]] = [] + entries: list[ChatMessageInfo] = [] match path: case "/": entries = [ - {"name": "/messages", "type": "directory", "size": 0}, - {"name": "/by_role", "type": "directory", "size": 0}, - { - "name": "/summary.json", - "type": "file", - "size": len(file_entries.get("/summary.json", b"")), - }, + ChatMessageInfo(name="/messages", type="directory", size=0), + ChatMessageInfo(name="/by_role", type="directory", size=0), + ChatMessageInfo( + name="/summary.json", + type="file", + size=len(file_entries.get("/summary.json", b"")), + ), ] case "/messages": for file_path, content in file_entries.items(): if file_path.startswith("/messages/"): - entries.append({"name": file_path, "type": "file", "size": len(content)}) + entries.append( + ChatMessageInfo(name=file_path, type="file", size=len(content)) + ) case "/by_role": entries = [ - {"name": "/by_role/user", "type": "directory", "size": 0}, - {"name": "/by_role/assistant", "type": "directory", "size": 0}, + ChatMessageInfo(name="/by_role/user", type="directory", size=0), + ChatMessageInfo(name="/by_role/assistant", type="directory", size=0), ] case "/by_role/user": for file_path, content in file_entries.items(): if file_path.startswith("/messages/") and "_user_" in file_path: - entries.append({"name": file_path, "type": "file", "size": len(content)}) + entries.append( + ChatMessageInfo(name=file_path, type="file", size=len(content)) + ) case "/by_role/assistant": for file_path, content in file_entries.items(): if file_path.startswith("/messages/") and "_assistant_" in file_path: - entries.append({"name": file_path, "type": "file", "size": len(content)}) + entries.append( + ChatMessageInfo(name=file_path, type="file", size=len(content)) + ) return entries if detail else [e["name"] for e in entries] @@ -148,32 +175,28 @@ async def _cat_file( ) -> bytes: """Read file content.""" path = self._normalize_path(path) - file_entries = self._get_file_entries() + file_entries = _get_file_entries(self._messages) if path in file_entries: return file_entries[path] raise FileNotFoundError(f"File not found: {path}") - async def _info(self, path: str, **kwargs: Any) -> dict[str, Any]: + async def _info(self, path: str, **kwargs: Any) -> ChatMessageInfo: """Get file/directory info.""" path = self._normalize_path(path) if path in self._get_dirs(): - return {"name": path, "type": "directory", "size": 0} + return ChatMessageInfo(name=path, type="directory", size=0) - file_entries = self._get_file_entries() + file_entries = _get_file_entries(self._messages) if path in file_entries: - return { - "name": path, - "type": "file", - "size": len(file_entries[path]), - } + return ChatMessageInfo(name=path, type="file", size=len(file_entries[path])) raise FileNotFoundError(f"Path not found: {path}") async def _exists(self, path: str, **kwargs: Any) -> bool: """Check if path exists.""" path = self._normalize_path(path) - return path in self._get_dirs() or path in self._get_file_entries() + return path in self._get_dirs() or path in _get_file_entries(self._messages) async def _isdir(self, path: str) -> bool: """Check if path is a directory.""" @@ -183,7 +206,7 @@ async def _isdir(self, path: str) -> bool: async def _isfile(self, path: str) -> bool: """Check if path is a file.""" path = self._normalize_path(path) - return path in self._get_file_entries() + return path in _get_file_entries(self._messages) # Write operations - all raise since this is read-only diff --git a/src/agentpool/messaging/event_manager.py b/src/agentpool/messaging/event_manager.py index 64a3a4724..27a1eab37 100644 --- a/src/agentpool/messaging/event_manager.py +++ b/src/agentpool/messaging/event_manager.py @@ -155,6 +155,8 @@ async def add_timed_event( timezone: Optional timezone (system default if None) skip_missed: Whether to skip missed executions """ + from evented.timed_watcher import TimeEventSource + config = TimeEventConfig( name=name or f"timed_{len(self._sources)}", schedule=schedule, @@ -162,7 +164,9 @@ async def add_timed_event( timezone=timezone, skip_missed=skip_missed, ) - return await self.add_source(config) # type: ignore[return-value] + source = await self.add_source(config) + assert isinstance(source, TimeEventSource) + return source async def add_email_watch( self, diff --git a/src/agentpool/messaging/message_container.py b/src/agentpool/messaging/message_container.py index 47cde3bea..3f420dc06 100644 --- a/src/agentpool/messaging/message_container.py +++ b/src/agentpool/messaging/message_container.py @@ -10,7 +10,7 @@ from agentpool.log import get_logger from agentpool.messaging import ChatMessage from agentpool.messaging.chat_filesystem import ChatMessageFileSystem -from agentpool.utils.count_tokens import batch_count_tokens +from agentpool.utils.count_tokens import count_tokens if TYPE_CHECKING: @@ -45,7 +45,7 @@ def get_history_tokens(self, fallback_model: str | None = None) -> int: Total token count across all messages """ # Use cost_info if available - total = sum(m.cost_info.token_usage.total_tokens for m in self if m.cost_info) + total = sum(m.usage.total_tokens for m in self if m.cost_info) # For messages without cost_info, estimate using tiktoken if msgs := [msg for msg in self if not msg.cost_info]: if fallback_model: @@ -53,7 +53,7 @@ def get_history_tokens(self, fallback_model: str | None = None) -> int: else: model_name = next((m.model_name for m in self if m.model_name), DEFAULT_TOKEN_MODEL) contents = [str(msg.content) for msg in msgs] - total += sum(batch_count_tokens(contents, model_name)) + total += count_tokens("\n".join(contents), model_name) return total diff --git a/src/agentpool/messaging/messages.py b/src/agentpool/messaging/messages.py index ddf3f96ed..e1973f5f8 100644 --- a/src/agentpool/messaging/messages.py +++ b/src/agentpool/messaging/messages.py @@ -11,27 +11,29 @@ from genai_prices import calc_price from pydantic import BaseModel from pydantic_ai import ( + AudioUrl, BaseToolReturnPart, BinaryContent, BuiltinToolCallPart, BuiltinToolReturnPart, FilePart, - FileUrl, + ImageUrl, ModelRequest, ModelResponse, - RequestUsage, + RunUsage, TextPart, ToolCallPart, ToolReturnPart, UserContent, UserPromptPart, + VideoUrl, ) import tokonomics from agentpool.common_types import MessageRole, SimpleJsonType # noqa: TC001 from agentpool.log import get_logger from agentpool.utils.inspection import dataclasses_no_defaults_repr -from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict +from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict, to_request_usage, to_run_usage from agentpool.utils.time_utils import get_now @@ -44,7 +46,6 @@ ModelMessage, ModelRequestPart, ModelResponsePart, - RunUsage, ) from agentpool.tools.tool_call_info import ToolCallInfo @@ -107,15 +108,16 @@ } -@dataclass(frozen=True) +@dataclass(kw_only=True) class TokenCost: """Combined token and cost tracking.""" - token_usage: RunUsage - """Token counts for prompt and completion""" total_cost: Decimal """Total cost in USD""" + def incr(self, other: TokenCost) -> None: + self.total_cost += other.total_cost + @classmethod async def from_usage( cls, @@ -160,10 +162,10 @@ async def from_usage( ) price = Decimal(cost.total_cost if cost else 0) - return cls(token_usage=usage, total_cost=price) + return cls(total_cost=price) -@dataclass +@dataclass(kw_only=True) class ChatMessage[TContent]: """Common message format for all UI types. @@ -215,7 +217,7 @@ class ChatMessage[TContent]: messages: list[ModelMessage] = field(default_factory=list) """List of messages which were generated during the the creation of this messsage.""" - usage: RequestUsage = field(default_factory=RequestUsage) + usage: RunUsage = field(default_factory=RunUsage) """Usage information for the request. This has a default to make tests easier, @@ -269,12 +271,12 @@ def to_pydantic_ai(self) -> Sequence[ModelMessage]: return self.messages match self.kind: case "request": - return [ModelRequest(parts=self.parts, instructions=None, run_id=self.message_id)] # type: ignore[arg-type] + return [ModelRequest(parts=self.parts, instructions=None, run_id=self.message_id)] # type: ignore[arg-type] # ty:ignore[invalid-argument-type] case "response": return [ ModelResponse( - parts=self.parts, # type: ignore[arg-type] - usage=self.usage, + parts=self.parts, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + usage=to_request_usage(self.usage), model_name=self.model_name, timestamp=self.timestamp, provider_name=self.provider_name, @@ -349,7 +351,7 @@ def from_pydantic_ai[TContentType]( role="assistant", content=content, messages=[message], - usage=usage, + usage=to_run_usage(usage), message_id=run_id or str(uuid.uuid4()), session_id=session_id, model_name=model_name, @@ -392,13 +394,10 @@ async def from_run_result[OutputDataT]( """ # Calculate costs - prefer provider-reported cost if available run_usage = result.usage() - usage = result.response.usage provider_cost = (result.response.provider_details or {}).get("cost") if provider_cost is not None: # Use actual cost from provider (e.g., OpenRouter returns this) - cost_info: TokenCost | None = TokenCost( - token_usage=run_usage, total_cost=Decimal(str(provider_cost)) - ) + cost_info: TokenCost | None = TokenCost(total_cost=Decimal(str(provider_cost))) else: # Fall back to calculated cost cost_info = await TokenCost.from_usage( @@ -415,14 +414,13 @@ async def from_run_result[OutputDataT]( finish_reason=result.response.finish_reason, messages=result.new_messages(), provider_response_id=result.response.provider_response_id, - usage=usage, + usage=run_usage, provider_name=result.response.provider_name, message_id=message_id or str(uuid.uuid4()), session_id=session_id, parent_id=parent_id, cost_info=cost_info, response_time=response_time, - provider_details={}, metadata=metadata or {}, ) @@ -455,15 +453,18 @@ def to_request(self) -> Self: case TextPart(content=content) | FilePart(content=content): # Text & File parts (images, etc.) become user content directly user_content.append(content) - case BaseToolReturnPart(content=(str() | FileUrl() | BinaryContent()) as content): - user_content.append(content) # type: ignore[arg-type] - case BaseToolReturnPart(content=list() as content_list): - # Handle sequence of content items - for item in content_list: - if isinstance(item, str | FileUrl | BinaryContent): - user_content.append(item) # type: ignore[arg-type] - else: - user_content.append(str(item)) + case BaseToolReturnPart(content=content): + match content: + case str() | ImageUrl() | AudioUrl() | VideoUrl() | BinaryContent(): + user_content.append(content) + case list() as content_list: + for item in content_list: + if isinstance( + item, str | VideoUrl | AudioUrl | ImageUrl | BinaryContent + ): + user_content.append(item) + else: + user_content.append(str(item)) case BaseToolReturnPart(): # Other tool return parts become user content strings user_content.append(part.model_response_str()) @@ -562,14 +563,15 @@ def format( env.filters["to_yaml"] = yamling.dump_yaml match style: + case "custom" if not template: + raise ValueError("Custom style requires a template") case "custom": - if not template: - raise ValueError("Custom style requires a template") + assert template template_str = template - case _ if style in MESSAGE_TEMPLATES: + case "simple" | "detailed" | "markdown": template_str = MESSAGE_TEMPLATES[style] - case _: - raise ValueError(f"Invalid style: {style}") + case _ as unreachable: + assert_never(unreachable) template_obj = env.from_string(template_str) vars_ = {**(self.__dict__), "show_metadata": show_metadata, "show_costs": show_costs} if variables: @@ -579,11 +581,7 @@ def format( def get_token_count(self) -> int: """Get token count, either from token usage or cost data.""" - from agentpool.utils.count_tokens import count_tokens - - if info := self.cost_info: - return info.token_usage.total_tokens - return count_tokens(str(self.usage.total_tokens), self.model_name) + return self.usage.total_tokens @dataclass diff --git a/src/agentpool/messaging/processing.py b/src/agentpool/messaging/processing.py index 1837bccc6..f0d707883 100644 --- a/src/agentpool/messaging/processing.py +++ b/src/agentpool/messaging/processing.py @@ -40,7 +40,6 @@ async def prepare_prompts( async def finalize_message( message: ChatMessage[Any], - previous_message: ChatMessage[Any] | None, node: MessageNode[Any, Any], connections: ConnectionManager, wait_for_connections: bool | None = None, @@ -49,7 +48,6 @@ async def finalize_message( Args: message: The response message to finalize - previous_message: The original user message (if any) node: The message node that produced the message connections: Connection manager for routing wait_for_connections: Whether to wait for connected nodes diff --git a/src/agentpool/models/agents.py b/src/agentpool/models/agents.py index 87d3ec53c..e454276af 100644 --- a/src/agentpool/models/agents.py +++ b/src/agentpool/models/agents.py @@ -121,7 +121,7 @@ class NativeAgentConfig(BaseAgentConfig): examples=[ ["webbrowser:open", "builtins:print"], [ - ImportToolConfig(import_path="webbrowser:open", name="web_browser"), # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + ImportToolConfig(import_path="webbrowser:open", name="web_browser"), # ty: ignore[invalid-argument-type] BashToolConfig(timeout=30.0), ], ], @@ -446,7 +446,7 @@ def get_system_prompts(self) -> list[BasePrompt]: ) prompts.append(static_prompt) case _ as unreachable: - assert_never(unreachable) # ty: ignore[type-assertion-failure] + assert_never(unreachable) return prompts def render_system_prompts(self, context: dict[str, Any] | None = None) -> list[str]: diff --git a/src/agentpool/models/claude_code_agents.py b/src/agentpool/models/claude_code_agents.py index 41f419449..4d279ecba 100644 --- a/src/agentpool/models/claude_code_agents.py +++ b/src/agentpool/models/claude_code_agents.py @@ -63,37 +63,78 @@ class AgentDefinition(Schema): """Agent definition configuration.""" - description: str - """Description of the agent.""" + description: str = Field(..., title="Agent Description", examples=["QA Assistant"]) + """A brief description of the agent's purpose.""" - prompt: str - """Prompt for the agent.""" + prompt: str = Field(..., title="Agent Prompt", examples=["Do XY"]) + """The prompt to use for this agent.""" - tools: list[str] | None = None - """List of tools the agent can use.""" + tools: list[str] | None = Field(default=None, title="Agent Tools", examples=["Bash"]) + """The tools this agent has access to.""" - model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None - """Model to use for the agent.""" + model: Literal["sonnet", "opus", "haiku", "inherit"] | str | None = Field( # noqa: PYI051 + default=None, + title="Agent Model", + examples=["sonnet"], + ) + """The model to use for this agent.""" + + memory: SettingSource | None = Field( + default=None, + title="Agent Memory", + examples=["user", "project"], + ) + + disallowed_tools: list[str] | None = Field( + default=None, + title="Disallowed Tools", + examples=["Bash"], + ) + """Tools this agent is not allowed to use.""" + + critical_system_reminder_experimental: str | None = Field( + default=None, + title="Critical System Reminder", + alias="criticalSystemReminder_EXPERIMENTAL", + ) + """Critical system reminder message to display to the user.""" - memory: SettingSource | None = None - """Memory type for the agent.""" + skills: list[str] | None = Field(default=None, title="Skills", examples=["my-skill"]) + """Skills this agent has.""" - disallowed_tools: list[str] | None = None - """List of tools the agent cannot use.""" + max_turns: int | None = Field(default=None, title="Max Turns") + """Maximum number of agentic turns (API round-trips) before stopping.""" - skills: list[str] | None = None - """List of skills the agent can use.""" + background: bool | None = Field(default=None, title="Run in Background") + """Whether this agent runs in the background.""" - max_turns: int | None = None - """Maximum number of turns the agent can take.""" + # hooks: AgentHooksConfig | None = Field(default=None, title="Agent Hooks") + # """Hook configurations for this agent.""" + + effort: Literal["low", "medium", "high", "max"] | int | None = Field( + default=None, + title="Reasoning effort", + examples=["high"], + ) + """Effort level for thinking depth.""" + + permission_mode: PermissionMode | None = Field( + default=None, + title="Permission Mode", + examples=["bypassPermissions"], + ) + """Permission mode for this agent.""" + + isolation: Literal["worktree"] | None = Field( + default=None, + title="Isolation Mode", + examples=["worktree"], + ) + """Isolation mode. ``"worktree"`` runs the agent in a separate git worktree.""" mcp_servers: dict[str, MCPServerConfig] | None = None """Configuration for MCP servers.""" - background: bool | None = None - """Run as background agent.""" - # critical_system_reminder_experimental: str | None = None - class ClaudeCodeAgentConfig(BaseAgentConfig): """Configuration for Claude Code agents. @@ -383,13 +424,11 @@ def get_subagent_configs(self) -> dict[str, CCAgentDefinition]: for server_name, server_config in (v.mcp_servers or {}).items(): match server_config: case StdioMCPServerConfig(command=command, args=args): - mcp_dct[server_name] = McpStdioServerConfig( - type="stdio", command=command, args=args - ) + mcp_dct[server_name] = McpStdioServerConfig(command=command, args=args) case StreamableHTTPMCPServerConfig(url=url): - mcp_dct[server_name] = McpHttpServerConfig(type="http", url=str(url)) + mcp_dct[server_name] = McpHttpServerConfig(url=str(url)) case SSEMCPServerConfig(url=url): - mcp_dct[server_name] = McpSSEServerConfig(type="sse", url=str(url)) + mcp_dct[server_name] = McpSSEServerConfig(url=str(url)) dumped = v.model_dump() dumped["mcp_servers"] = mcp_dct dct[k] = CCAgentDefinition(**dumped) diff --git a/src/agentpool/models/codex_agents.py b/src/agentpool/models/codex_agents.py index 03067f0d4..db8541eb6 100644 --- a/src/agentpool/models/codex_agents.py +++ b/src/agentpool/models/codex_agents.py @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING, Any, Literal, assert_never +from codexed import ApprovalPolicy, Personality, ReasoningEffort, SandboxMode # noqa: TC002 from pydantic import ConfigDict, Field from agentpool.models.agents import AnyToolConfig # noqa: TC001 from agentpool.models.fields import OutputTypeField # noqa: TC001 from agentpool_config.nodes import BaseAgentConfig -from codex_adapter import ApprovalPolicy, Personality, ReasoningEffort, SandboxMode # noqa: TC001 if TYPE_CHECKING: diff --git a/src/agentpool/prompts/parts/prompt_suggestions.md b/src/agentpool/prompts/parts/prompt_suggestions.md new file mode 100644 index 000000000..707bd242e --- /dev/null +++ b/src/agentpool/prompts/parts/prompt_suggestions.md @@ -0,0 +1,30 @@ +[SUGGESTION MODE: Suggest what the user might naturally type next into Claude Code.] + +FIRST: Look at the user's recent messages and original request. + +Your job is to predict what THEY would type - not what you think they should do. + +THE TEST: Would they think "I was just about to type that"? + +EXAMPLES: +User asked "fix the bug and run tests", bug is fixed → "run the tests" +After code written → "try it out" +Claude offers options → suggest the one the user would likely pick, based on conversation +Claude asks to continue → "yes" or "go ahead" +Task complete, obvious follow-up → "commit this" or "push it" +After error or misunderstanding → silence (let them assess/correct) + +Be specific: "run the tests" beats "continue". + +NEVER SUGGEST: +- Evaluative ("looks good", "thanks") +- Questions ("what about...?") +- Claude-voice ("Let me...", "I'll...", "Here's...") +- New ideas they didn't ask about +- Multiple sentences + +Stay silent if the next step isn't obvious from what the user said. + +Format: 2-12 words, match the user's style. Or nothing. + +Reply with ONLY the suggestion, no quotes or explanation. diff --git a/src/agentpool/prompts/prompts.py b/src/agentpool/prompts/prompts.py index 8ae96b8c4..f88cca81e 100644 --- a/src/agentpool/prompts/prompts.py +++ b/src/agentpool/prompts/prompts.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: from collections.abc import Mapping + from fastmcp.prompts import Prompt as FastMCPPrompt from fastmcp.prompts.function_prompt import FunctionPrompt - from fastmcp.prompts.prompt import Prompt as FastMCPPrompt from mcp.types import Prompt as MCPPrompt, PromptArgument from pydantic_ai import ModelRequestPart from slashed import CommandContext @@ -186,7 +186,7 @@ def to_mcp_prompt(self) -> MCPPrompt: return MCPPrompt(name=self.name, description=self.description, arguments=args) def to_fastmcp_prompt(self) -> FastMCPPrompt: - from fastmcp.prompts.prompt import ( + from fastmcp.prompts import ( Prompt as FastMCPPrompt, PromptArgument as FastMCPArgument, ) diff --git a/src/agentpool/resource_providers/aggregating.py b/src/agentpool/resource_providers/aggregating.py index af208a08b..3784fa33b 100644 --- a/src/agentpool/resource_providers/aggregating.py +++ b/src/agentpool/resource_providers/aggregating.py @@ -113,7 +113,7 @@ async def get_tools(self) -> Sequence[Tool]: # Type narrowing: we know it's CodeModeResourceProvider at this point codemode = self._codemode_provider assert isinstance(codemode, CodeModeResourceProvider) - codemode.providers = [static] + codemode.providers = [static] # ty: ignore[invalid-assignment] return list(await self._codemode_provider.get_tools()) diff --git a/src/agentpool/resource_providers/mcp_provider.py b/src/agentpool/resource_providers/mcp_provider.py index 44f4f5b9d..1529dd0d7 100644 --- a/src/agentpool/resource_providers/mcp_provider.py +++ b/src/agentpool/resource_providers/mcp_provider.py @@ -122,7 +122,7 @@ async def __aexit__( logger.exception(msg, exc_info=e) raise RuntimeError(msg) from e - async def _on_tools_changed(self) -> None: + async def _on_tools_changed(self, _meta: dict[str, Any]) -> None: """Callback when tools change on the MCP server.""" logger.info("MCP tool list changed, refreshing provider cache") self._saved_enabled_states = {t.name: t.enabled for t in self._tools_cache or []} @@ -130,14 +130,14 @@ async def _on_tools_changed(self) -> None: # Notify subscribers via signal await self.tools_changed.emit(self.create_change_event("tools")) - async def _on_prompts_changed(self) -> None: + async def _on_prompts_changed(self, _meta: dict[str, Any]) -> None: """Callback when prompts change on the MCP server.""" logger.info("MCP prompt list changed, refreshing provider cache") self._prompts_cache = None # Notify subscribers via signal await self.prompts_changed.emit(self.create_change_event("prompts")) - async def _on_resources_changed(self) -> None: + async def _on_resources_changed(self, _meta: dict[str, Any]) -> None: """Callback when resources change on the MCP server.""" logger.info("MCP resource list changed, refreshing provider cache") self._resources_cache = None diff --git a/src/agentpool/resource_providers/plan_provider.py b/src/agentpool/resource_providers/plan_provider.py index b744aae52..44487c64d 100644 --- a/src/agentpool/resource_providers/plan_provider.py +++ b/src/agentpool/resource_providers/plan_provider.py @@ -9,21 +9,14 @@ from agentpool.agents.events import TextContentItem from agentpool.resource_providers import ResourceProvider from agentpool.tools.base import ToolResult -from agentpool.utils.todos import ( - PRIORITY_LABELS, - STATUS_ICONS, - PlanEntryPriority, # noqa: F401 - PlanEntryStatus, # noqa: F401 - TodoPriority, # noqa: TC001 - TodoStatus, # noqa: TC001 -) +from agentpool.utils.todos import PRIORITY_LABELS, STATUS_ICONS if TYPE_CHECKING: from collections.abc import Sequence from agentpool.tools.base import Tool - from agentpool.utils.todos import PlanEntry, TodoTracker + from agentpool.utils.todos import PlanEntry, TodoPriority, TodoStatus, TodoTracker PlanToolMode = Literal["granular", "declarative"] @@ -160,12 +153,13 @@ async def set_plan( await self._emit_plan_update(agent_ctx) # Build summary for user feedback entry_count = len(tracker.entries) - if entry_count == 0: - title = "Cleared plan" - elif entry_count == 1: - title = "Set plan with 1 task" - else: - title = f"Set plan with {entry_count} tasks" + match entry_count: + case 0: + title = "Cleared plan" + case 1: + title = "Set plan with 1 task" + case _: + title = f"Set plan with {entry_count} tasks" # Format entries list for details if tracker.entries: diff --git a/src/agentpool/storage/manager.py b/src/agentpool/storage/manager.py index cd77d9cf6..9255d876f 100644 --- a/src/agentpool/storage/manager.py +++ b/src/agentpool/storage/manager.py @@ -590,6 +590,7 @@ async def replace_conversation_messages( message_id=message.message_id, parent_id=message.parent_id, model_name=message.model_name, + usage=message.usage, cost_info=message.cost_info, response_time=message.response_time, timestamp=message.timestamp, @@ -837,18 +838,6 @@ async def touch_project(self, project_id: str) -> None: project_id=project_id, ) - # Session data methods - - def generate_session_id(self) -> str: - """Generate a unique, chronologically sortable session ID. - - Uses OpenCode-compatible format: ses_{hex_timestamp}{random_base62} - IDs are lexicographically sortable by creation time. - """ - from agentpool.utils.identifiers import generate_session_id - - return generate_session_id() - @method_spawner async def save_session(self, data: SessionData) -> None: """Save or update session data in the primary provider. diff --git a/src/agentpool/talk/stats.py b/src/agentpool/talk/stats.py index 9915abe54..8447459fd 100644 --- a/src/agentpool/talk/stats.py +++ b/src/agentpool/talk/stats.py @@ -37,7 +37,7 @@ def last_message_time(self) -> datetime | None: @property def token_count(self) -> int: """Total tokens used.""" - return sum(m.cost_info.token_usage.total_tokens for m in self.messages if m.cost_info) + return sum(m.usage.total_tokens for m in self.messages) @property def tool_calls(self) -> list[ToolCallInfo]: @@ -104,7 +104,7 @@ def num_connections(self) -> int: @property def token_count(self) -> int: """Total tokens across all connections.""" - return sum(m.cost_info.token_usage.total_tokens for m in self.messages if m.cost_info) + return sum(m.usage.total_tokens for m in self.messages) @property def total_cost(self) -> float: diff --git a/src/agentpool/talk/talk.py b/src/agentpool/talk/talk.py index 9d39f8d28..0460382a9 100644 --- a/src/agentpool/talk/talk.py +++ b/src/agentpool/talk/talk.py @@ -193,7 +193,7 @@ async def _evaluate_condition( registry=registry, talk=self, ) - return await execute(condition, ctx) + return await execute(condition, ctx) # ty: ignore[invalid-return-type] def on_event( self, @@ -275,7 +275,7 @@ async def _handle_message( source=self.source, targets=target_list, queued=self.queued, - connection_type=self.connection_type, # pyright: ignore + connection_type=self.connection_type, ) ) # 8. if we have targets, update stats and emit message forwarded @@ -318,7 +318,7 @@ async def add_context() -> None: case BaseTeam(): # Add context to all team members for agent in target.iter_agents(): - agent.staged_content.add_text(str(message.content)) # ty: ignore[unresolved-attribute] + agent.staged_content.add_text(str(message.content)) case BaseAgent(): target.staged_content.add_text(str(message.content)) diff --git a/src/agentpool/tool_impls/list_directory/tool.py b/src/agentpool/tool_impls/list_directory/tool.py index c28c5f502..34366ee85 100644 --- a/src/agentpool/tool_impls/list_directory/tool.py +++ b/src/agentpool/tool_impls/list_directory/tool.py @@ -136,7 +136,7 @@ async def _list_directory( metadata={"count": total_found, "truncated": True}, ) - for file_path, file_info in paths.items(): # pyright: ignore[reportAttributeAccessIssue] + for file_path, file_info in paths.items(): rel_path = os.path.relpath(str(file_path), path) # Skip excluded patterns @@ -144,10 +144,10 @@ async def _list_directory( continue # Use type from glob detail info, falling back to isdir only if needed - is_dir = await is_directory(fs, file_path, entry_type=file_info.get("type")) # pyright: ignore[reportArgumentType] + is_dir = await is_directory(fs, file_path, entry_type=file_info.get("type")) item_info = { - "name": Path(file_path).name, # pyright: ignore[reportArgumentType] + "name": Path(file_path).name, "path": file_path, "relative_path": rel_path, "size": file_info.get("size", 0), diff --git a/src/agentpool/tool_impls/sandbox_bash/__init__.py b/src/agentpool/tool_impls/sandbox_bash/__init__.py new file mode 100644 index 000000000..7b6a513a7 --- /dev/null +++ b/src/agentpool/tool_impls/sandbox_bash/__init__.py @@ -0,0 +1,67 @@ +"""Sandboxed bash execution via bashkit. + +Provides a virtual bash interpreter that runs entirely in-process with no real +filesystem access. Built on bashkit (Rust) for safe, sandboxed command execution +in AI agent workloads. +""" + +from __future__ import annotations + +from typing import Literal + +from agentpool.tool_impls.sandbox_bash.tool import SandboxBashTool +from agentpool.tool_impls.sandbox_bash.wrapper import SandboxBash, SandboxExecResult +from agentpool_config.tools import ToolHints + + +__all__ = [ + "SandboxBash", + "SandboxBashTool", + "SandboxExecResult", + "create_sandbox_bash_tool", +] + +NAME = "sandbox_bash" +DESCRIPTION = ( + "Execute bash commands in a sandboxed virtual environment. " + "All file operations happen in a virtual filesystem — nothing touches the real host." +) +CATEGORY: Literal["execute"] = "execute" +HINTS = ToolHints(destructive=False, idempotent=False, open_world=False, read_only=False) + + +def create_sandbox_bash_tool( + *, + username: str | None = None, + hostname: str | None = None, + max_commands: int | None = None, + max_loop_iterations: int | None = None, + name: str = NAME, + description: str = DESCRIPTION, + requires_confirmation: bool = False, +) -> SandboxBashTool: + """Create a configured SandboxBashTool instance. + + Args: + username: Custom username for the virtual environment (whoami). + hostname: Custom hostname for the virtual environment. + max_commands: Maximum number of commands to execute. + max_loop_iterations: Maximum loop iterations allowed. + name: Tool name override. + description: Tool description override. + requires_confirmation: Whether tool execution needs confirmation. + + Returns: + Configured SandboxBashTool instance. + """ + return SandboxBashTool( + name=name, + description=description, + category=CATEGORY, + hints=HINTS, + username=username, + hostname=hostname, + max_commands=max_commands, + max_loop_iterations=max_loop_iterations, + requires_confirmation=requires_confirmation, + ) diff --git a/src/agentpool/tool_impls/sandbox_bash/tool.py b/src/agentpool/tool_impls/sandbox_bash/tool.py new file mode 100644 index 000000000..b72184c4b --- /dev/null +++ b/src/agentpool/tool_impls/sandbox_bash/tool.py @@ -0,0 +1,101 @@ +"""Sandboxed bash tool for agentpool's tool framework.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from agentpool.log import get_logger +from agentpool.tool_impls.sandbox_bash.wrapper import SandboxBash +from agentpool.tools.base import Tool, ToolResult + + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from agentpool.agents.context import AgentContext + +logger = get_logger(__name__) + + +@dataclass +class SandboxBashTool(Tool[ToolResult]): + """Execute bash commands in a sandboxed virtual environment. + + All commands run in-process against a virtual filesystem — no real filesystem + access, no containers, no subprocesses. State (files, variables) persists + between calls within the same tool instance. + + Uses bashkit (Rust) under the hood for fast, safe execution. + """ + + username: str | None = None + """Custom username for the virtual environment.""" + + hostname: str | None = None + """Custom hostname for the virtual environment.""" + + max_commands: int | None = None + """Maximum number of commands to execute.""" + + max_loop_iterations: int | None = None + """Maximum loop iterations allowed.""" + + _sandbox: SandboxBash | None = field(default=None, init=False, repr=False) + + def _get_sandbox(self) -> SandboxBash: + """Get or create the sandbox instance (lazy initialization).""" + if self._sandbox is None: + self._sandbox = SandboxBash( + username=self.username, + hostname=self.hostname, + max_commands=self.max_commands, + max_loop_iterations=self.max_loop_iterations, + ) + return self._sandbox + + def get_callable(self) -> Callable[..., Awaitable[ToolResult]]: + """Return the execute method as the callable.""" + return self._execute + + async def _execute( + self, + ctx: AgentContext, + commands: str, + ) -> ToolResult: + """Execute bash commands in a sandboxed virtual environment. + + Runs commands in an isolated bash interpreter with a virtual filesystem. + No access to the real filesystem or network (unless explicitly allowed). + State persists between calls — files created in one call are available + in subsequent calls. + + Args: + ctx: Agent context for event emission. + commands: Bash commands to execute (like ``bash -c "commands"``). + """ + sandbox = self._get_sandbox() + result = await sandbox.execute(commands) + + logger.debug( + "Sandbox bash executed", + commands=commands[:100], + exit_code=result.exit_code, + stdout_len=len(result.stdout), + ) + + return ToolResult( + content=result.output, + metadata={ + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "error": result.error, + "description": commands, + }, + ) + + def reset(self) -> None: + """Reset the sandbox, clearing all virtual filesystem state.""" + if self._sandbox is not None: + self._sandbox.reset() diff --git a/src/agentpool/tool_impls/sandbox_bash/wrapper.py b/src/agentpool/tool_impls/sandbox_bash/wrapper.py new file mode 100644 index 000000000..3cb0cf09f --- /dev/null +++ b/src/agentpool/tool_impls/sandbox_bash/wrapper.py @@ -0,0 +1,498 @@ +"""Pythonic wrappers around bashkit's native types. + +Provides ``SandboxBash`` (wraps ``bashkit.Bash``) and ``SandboxExecResult`` +(wraps ``bashkit.ExecResult``) with richer APIs, type safety, and integration +points for agentpool infrastructure. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, Self + +from bashkit import Bash, ScriptedTool + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from bashkit import ExecResult + + +class ExternalHandler(Protocol): + """Protocol for external function handlers called from embedded Python.""" + + async def __call__( + self, + fn_name: str, + args: list[Any], + kwargs: dict[str, Any], + ) -> Any: ... + + +__all__ = [ + "ExternalHandler", + "SandboxBash", + "SandboxExecResult", + "SandboxScriptedTool", +] + + +@dataclass(frozen=True, slots=True) +class SandboxExecResult: + """Immutable, richly-typed result from a sandboxed bash execution. + + Wraps bashkit's ``ExecResult`` with additional convenience methods. + """ + + stdout: str + """Standard output from the command.""" + + stderr: str + """Standard error from the command.""" + + exit_code: int + """Process exit code (0 = success).""" + + error: str | None + """Error message if execution failed at the interpreter level.""" + + @property + def success(self) -> bool: + """Whether the command completed successfully.""" + return self.exit_code == 0 + + @property + def output(self) -> str: + """Combined stdout and stderr, suitable for LLM consumption.""" + parts: list[str] = [] + if self.stdout: + parts.append(self.stdout) + if self.error: + parts.append(f"Error: {self.error}") + if self.stderr: + parts.append(f"STDERR: {self.stderr}") + if self.exit_code != 0: + parts.append(f"[Exit code: {self.exit_code}]") + return "\n".join(parts) if parts else "[No output]" + + @classmethod + def from_native(cls, result: ExecResult) -> SandboxExecResult: + """Create from a native bashkit ExecResult.""" + return cls( + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + error=result.error, + ) + + def raise_on_error(self) -> Self: + """Return self if successful, raise ``SandboxExecutionError`` otherwise.""" + if not self.success: + msg = self.error or self.stderr or f"Command failed with exit code {self.exit_code}" + raise SandboxExecutionError(msg, result=self) + return self + + +class SandboxExecutionError(RuntimeError): + """Raised when a sandboxed command fails and ``raise_on_error()`` is used.""" + + def __init__(self, message: str, *, result: SandboxExecResult) -> None: + super().__init__(message) + self.result = result + + +class SandboxBash: + """Pythonic wrapper around bashkit's ``Bash`` interpreter. + + Provides a stateful, sandboxed bash environment with a virtual filesystem. + Files created in one ``execute()`` call persist for subsequent calls. + + Example:: + + async with SandboxBash(username="agent") as bash: + result = await bash.execute("echo hello") + print(result.stdout) # hello + + await bash.execute("echo data > /tmp/file.txt") + content = await bash.read_file("/tmp/file.txt") + print(content) # data + """ + + def __init__( + self, + *, + username: str | None = None, + hostname: str | None = None, + max_commands: int | None = None, + max_loop_iterations: int | None = None, + python: bool = False, + external_functions: list[str] | None = None, + external_handler: ExternalHandler | None = None, + ) -> None: + """Initialize a sandboxed bash interpreter. + + Args: + username: Custom username for the virtual environment. + hostname: Custom hostname for the virtual environment. + max_commands: Maximum total commands allowed. + max_loop_iterations: Maximum loop iterations allowed. + python: Enable embedded Python interpreter (Monty). + external_functions: Function names callable from embedded Python. + external_handler: Async handler for external function calls from Python. + """ + self._bash = Bash( + username=username, + hostname=hostname, + max_commands=max_commands, + max_loop_iterations=max_loop_iterations, + python=python, + external_functions=external_functions, + external_handler=external_handler, + ) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + self.reset() + + async def execute(self, commands: str) -> SandboxExecResult: + """Execute bash commands asynchronously. + + Args: + commands: Bash commands to execute. + + Returns: + Structured execution result. + """ + native_result = await self._bash.execute(commands) + return SandboxExecResult.from_native(native_result) + + def execute_sync(self, commands: str) -> SandboxExecResult: + """Execute bash commands synchronously. + + Args: + commands: Bash commands to execute. + + Returns: + Structured execution result. + """ + native_result = self._bash.execute_sync(commands) + return SandboxExecResult.from_native(native_result) + + def cancel(self) -> None: + """Cancel any currently running execution.""" + self._bash.cancel() + + def reset(self) -> None: + """Reset interpreter state (clears filesystem, variables, etc.).""" + self._bash.reset() + + async def run(self, commands: str) -> SandboxExecResult: + """Execute commands, raising on failure. + + Convenience method that calls ``execute()`` then ``raise_on_error()``. + + Args: + commands: Bash commands to execute. + + Returns: + Result (guaranteed successful). + + Raises: + SandboxExecutionError: If the command fails. + """ + result = await self.execute(commands) + return result.raise_on_error() + + async def read_file(self, path: str) -> str: + """Read a file from the virtual filesystem. + + Args: + path: Absolute path in the virtual filesystem. + + Returns: + File contents as a string. + + Raises: + SandboxExecutionError: If the file cannot be read. + """ + result = await self.run(f"cat {_shell_quote(path)}") + return result.stdout + + async def write_file(self, path: str, content: str) -> None: + """Write content to a file in the virtual filesystem. + + Args: + path: Absolute path in the virtual filesystem. + content: Content to write. + + Raises: + SandboxExecutionError: If the write fails. + """ + import secrets + + delimiter = f"AGENTPOOL_EOF_{secrets.token_hex(8)}" + cmd = f"cat > {_shell_quote(path)} << '{delimiter}'\n{content}\n{delimiter}" + await self.run(cmd) + + async def file_exists(self, path: str) -> bool: + """Check whether a file exists in the virtual filesystem. + + Args: + path: Absolute path to check. + + Returns: + True if the file exists. + """ + result = await self.execute(f"test -e {_shell_quote(path)}") + return result.success + + async def list_dir(self, path: str = ".") -> list[str]: + """List directory contents in the virtual filesystem. + + Args: + path: Directory path (defaults to cwd). + + Returns: + List of filenames. + + Raises: + SandboxExecutionError: If the directory cannot be listed. + """ + result = await self.run(f"ls -1 {_shell_quote(path)}") + return [line for line in result.stdout.splitlines() if line] + + async def mkdir(self, path: str, parents: bool = True) -> None: + """Create a directory in the virtual filesystem. + + Args: + path: Directory path to create. + parents: If True, create parent directories as needed. + + Raises: + SandboxExecutionError: If directory creation fails. + """ + flag = " -p" if parents else "" + await self.run(f"mkdir{flag} {_shell_quote(path)}") + + async def remove(self, path: str, recursive: bool = False) -> None: + """Remove a file or directory from the virtual filesystem. + + Args: + path: Path to remove. + recursive: If True, remove directories recursively. + + Raises: + SandboxExecutionError: If removal fails. + """ + flag = " -rf" if recursive else "" + await self.run(f"rm{flag} {_shell_quote(path)}") + + async def get_env(self, key: str) -> str | None: + """Get an environment variable value. + + Args: + key: Environment variable name. + + Returns: + Variable value, or None if not set. + """ + result = await self.execute(f'printf "%s" "${{{_shell_quote_var(key)}}}"') + if not result.success: + return None + return result.stdout or None + + async def set_env(self, key: str, value: str) -> None: + """Set an environment variable. + + Args: + key: Environment variable name. + value: Value to set. + """ + await self.run(f"export {_shell_quote_var(key)}={_shell_quote(value)}") + + +class SandboxScriptedTool: + """Pythonic wrapper around bashkit's ``ScriptedTool``. + + Register Python callbacks as bash builtins, then execute bash scripts + that orchestrate all registered tools via pipes, loops, and branching. + + Example:: + + tool = SandboxScriptedTool("api") + tool.add_tool( + "get_user", + "Fetch user by ID", + callback=lambda params, stdin=None: '{"name": "Alice"}', + ) + result = await tool.execute("get_user --id 1 | jq -r '.name'") + print(result.stdout) # Alice + """ + + def __init__( + self, + name: str, + *, + short_description: str | None = None, + max_commands: int | None = None, + max_loop_iterations: int | None = None, + ) -> None: + """Initialize a scripted tool. + + Args: + name: Name for this tool composition. + short_description: Brief description of what the composed tool does. + max_commands: Maximum total commands allowed. + max_loop_iterations: Maximum loop iterations allowed. + """ + self._tool = ScriptedTool( + name, + short_description=short_description, + max_commands=max_commands, + max_loop_iterations=max_loop_iterations, + ) + + @property + def name(self) -> str: + """Tool name.""" + return self._tool.name + + @property + def tool_count(self) -> int: + """Number of registered sub-tools.""" + return self._tool.tool_count() + + def add_tool( + self, + name: str, + description: str, + callback: Callable[[dict[str, Any], str | None], str], + *, + schema: dict[str, Any] | None = None, + ) -> Self: + """Register a Python callback as a bash builtin. + + Args: + name: Command name in bash. + description: Human-readable description for LLM tool-use. + callback: Python function ``(params, stdin) -> stdout_string``. + schema: Optional JSON Schema for the tool's parameters. + + Returns: + Self for chaining. + """ + self._tool.add_tool(name, description, callback=callback, schema=schema) + return self + + def env(self, key: str, value: str) -> Self: + """Set an environment variable for the scripted tool. + + Args: + key: Variable name. + value: Variable value. + + Returns: + Self for chaining. + """ + self._tool.env(key, value) + return self + + async def execute(self, commands: str) -> SandboxExecResult: + """Execute a bash script that may invoke registered tools. + + Args: + commands: Bash script to execute. + + Returns: + Structured execution result. + """ + native_result = await self._tool.execute(commands) + return SandboxExecResult.from_native(native_result) + + def execute_sync(self, commands: str) -> SandboxExecResult: + """Execute a bash script synchronously. + + Args: + commands: Bash script to execute. + + Returns: + Structured execution result. + """ + native_result = self._tool.execute_sync(commands) + return SandboxExecResult.from_native(native_result) + + def description(self) -> str: + """Get token-efficient tool description.""" + return self._tool.description() + + def help(self) -> str: + """Get Markdown help document.""" + return self._tool.help() + + def system_prompt(self) -> str: + """Get system prompt for LLM integration.""" + return self._tool.system_prompt() + + def input_schema(self) -> str: + """Get JSON input schema.""" + return self._tool.input_schema() + + def output_schema(self) -> str: + """Get JSON output schema.""" + return self._tool.output_schema() + + +@asynccontextmanager +async def sandbox_bash( + *, + username: str | None = None, + hostname: str | None = None, + max_commands: int | None = None, + max_loop_iterations: int | None = None, +) -> AsyncIterator[SandboxBash]: + """Context manager for creating a sandboxed bash environment. + + Args: + username: Custom username for the virtual environment. + hostname: Custom hostname for the virtual environment. + max_commands: Maximum total commands allowed. + max_loop_iterations: Maximum loop iterations allowed. + + Yields: + Configured SandboxBash instance. + """ + bash = SandboxBash( + username=username, + hostname=hostname, + max_commands=max_commands, + max_loop_iterations=max_loop_iterations, + ) + try: + yield bash + finally: + bash.reset() + + +def _shell_quote(value: str) -> str: + """Quote a value for safe use in shell commands.""" + import shlex + + return shlex.quote(value) + + +def _shell_quote_var(name: str) -> str: + """Validate and return an environment variable name. + + Raises: + ValueError: If the name contains invalid characters. + """ + import re + + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name): + msg = f"Invalid environment variable name: {name!r}" + raise ValueError(msg) + return name diff --git a/src/agentpool/tools/base.py b/src/agentpool/tools/base.py index 1fa24a18c..a5e8cbd9b 100644 --- a/src/agentpool/tools/base.py +++ b/src/agentpool/tools/base.py @@ -5,10 +5,10 @@ from abc import abstractmethod from dataclasses import dataclass, field import inspect -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast, get_type_hints import logfire -from pydantic_ai.tools import Tool as PydanticAiTool +from pydantic_ai import RunContext, Tool as PydanticAiTool import schemez from agentpool.log import get_logger @@ -106,9 +106,6 @@ class Tool[TOutputType = Any]: category: ToolKind | None = None """The category of the tool.""" - instructions: str | None = None - """Instructions for how to use this tool effectively.""" - __repr__ = dataclasses_no_defaults_repr @abstractmethod @@ -130,8 +127,6 @@ def to_pydantic_ai(self) -> PydanticAiTool: @property def schema_obj(self) -> FunctionSchema: """Get the OpenAI function schema for the tool.""" - from pydantic_ai import RunContext - from agentpool.agents.context import AgentContext return schemez.create_schema( @@ -141,6 +136,27 @@ def schema_obj(self) -> FunctionSchema: exclude_types=[AgentContext, RunContext], ) + @property + def output_schema(self) -> dict[str, Any] | None: + """Get the MCP-facing output schema, unwrapping internal wrapper types. + + Returns None for tools returning ToolResult (internal transport wrapper) + or generic object types where no meaningful schema can be advertised. + Returns the JSON schema dict for tools with concrete return types. + """ + fn = self.get_callable() + try: + hints = get_type_hints(fn) + except Exception: # noqa: BLE001 + return None + ret = hints.get("return") + if ret is None or ret is ToolResult: + return None + returns = self.schema_obj.returns + if returns == {"type": "object"}: + return None + return returns + @property def schema(self) -> schemez.OpenAIFunctionTool: """Get the OpenAI function schema for the tool.""" @@ -162,9 +178,9 @@ def matches_filter(self, state: ToolState) -> bool: @property def parameters(self) -> list[ToolParameter]: """Get information about tool parameters.""" - schema = self.schema["function"] - properties: dict[str, Property] = schema.get("properties", {}) # type: ignore[assignment] - required: list[str] = schema.get("required", []) # type: ignore[assignment] + params = self.schema["function"]["parameters"] + properties: dict[str, Property] = params["properties"] + required: list[str] = params.get("required", []) return [ ToolParameter( @@ -195,15 +211,7 @@ async def execute(self, *args: Any, **kwargs: Any) -> Any: return await execute(self.get_callable(), *args, **kwargs, use_thread=True) async def execute_and_unwrap(self, *args: Any, **kwargs: Any) -> Any: - """Execute tool and unwrap ToolResult if present. - - This is a convenience method for tests and direct tool usage that want - plain content instead of ToolResult objects. - - Returns: - If tool returns ToolResult, returns ToolResult.content. - Otherwise returns the raw result. - """ + """Execute tool and unwrap ToolResult if present.""" result = await self.execute(*args, **kwargs) if isinstance(result, ToolResult): return result.content @@ -221,8 +229,7 @@ def from_code( exec(code, namespace) func = next((v for v in namespace.values() if callable(v)), None) if not func: - msg = "No callable found in provided code" - raise ValueError(msg) + raise ValueError("No callable found in provided code") return FunctionTool.from_callable( func, name_override=name, description_override=description ) @@ -268,13 +275,13 @@ def get_mcp_tool_annotations(self) -> ToolAnnotations: def to_mcp_tool(self) -> MCPTool: """Convert internal Tool to MCP Tool.""" - schema = self.schema from mcp.types import Tool as MCPTool + schema = self.schema return MCPTool( name=schema["function"]["name"], description=schema["function"]["description"], - inputSchema=schema["function"]["parameters"], # pyright: ignore + inputSchema=cast(dict[str, Any], schema["function"]["parameters"]), annotations=self.get_mcp_tool_annotations(), ) @@ -283,7 +290,7 @@ def to_mcp_tool(self) -> MCPTool: class FunctionTool[TOutputType = Any](Tool[TOutputType]): """Tool wrapping a plain callable function.""" - callable: Callable[..., TOutputType | Awaitable[TOutputType]] = field(default=lambda: None) # type: ignore[assignment] + callable: Callable[..., TOutputType | Awaitable[TOutputType]] = field(kw_only=True) """The actual tool implementation.""" def get_callable(self) -> Callable[..., TOutputType | Awaitable[TOutputType]]: @@ -305,26 +312,24 @@ def from_callable( **kwargs: Any, ) -> FunctionTool[TOutputType]: """Create a FunctionTool from a callable or import path string.""" + from agentpool.utils import importing + if isinstance(fn, str): import_path = fn - from agentpool.utils import importing - callable_obj = importing.import_callable(fn) name = getattr(callable_obj, "__name__", "unknown") else: callable_obj = fn - module = fn.__module__ if hasattr(fn, "__qualname__"): # Regular function name = get_fn_name(fn) - import_path = f"{module}.{get_fn_qualname(fn)}" + import_path = f"{fn.__module__}.{get_fn_qualname(fn)}" else: # Instance with __call__ method name = fn.__class__.__name__ - import_path = f"{module}.{fn.__class__.__qualname__}" - + import_path = f"{fn.__module__}.{fn.__class__.__qualname__}" return cls( name=name_override or name, description=description_override or inspect.getdoc(callable_obj) or "", - callable=callable_obj, # pyright: ignore[reportArgumentType] + callable=callable_obj, import_path=import_path, schema_override=schema_override, category=category, diff --git a/src/agentpool/tools/manager.py b/src/agentpool/tools/manager.py index 07a53a614..a00ae4ba9 100644 --- a/src/agentpool/tools/manager.py +++ b/src/agentpool/tools/manager.py @@ -171,11 +171,9 @@ async def list_prompts(self) -> list[MCPClientPrompt]: # Get prompts from all external providers (check if they're MCP providers) for provider in self.external_providers: if isinstance(provider, MCPManager): + agg_provider = provider.get_aggregating_provider() try: - # Get prompts from MCP providers via the aggregating provider - agg_provider = provider.get_aggregating_provider() prompts = await agg_provider.get_prompts() - # Filter to only MCPClientPrompt instances mcp_prompts = [p for p in prompts if isinstance(p, MCPPrompt)] all_prompts.extend(mcp_prompts) except Exception: diff --git a/src/agentpool/ui/mock_provider.py b/src/agentpool/ui/mock_provider.py index aba76f4f4..542d7f752 100644 --- a/src/agentpool/ui/mock_provider.py +++ b/src/agentpool/ui/mock_provider.py @@ -39,7 +39,9 @@ def __init__( ) -> None: self.input_response = input_response self.tool_confirmation: ConfirmationResult = tool_confirmation - self.elicitation_response = elicitation_response or {"response": "mock response"} + self.elicitation_response: dict[str, Any] = elicitation_response or { + "response": "mock response" + } self.calls: list[InputCall] = [] async def get_input( diff --git a/src/agentpool/ui/stdlib_provider.py b/src/agentpool/ui/stdlib_provider.py index 85413c771..43e384049 100644 --- a/src/agentpool/ui/stdlib_provider.py +++ b/src/agentpool/ui/stdlib_provider.py @@ -91,33 +91,35 @@ async def get_elicitation( params: types.ElicitRequestParams, ) -> types.ElicitResult | types.ErrorData: """Get user response to elicitation request using stdlib input.""" + print(f"\n{params.message}", file=sys.stderr) try: - print(f"\n{params.message}", file=sys.stderr) - # URL mode: prompt user to open external URL - if isinstance(params, types.ElicitRequestURLParams): - print(f"URL: {params.url}", file=sys.stderr) - print("Open this URL? [y/n]: ", end="", file=sys.stderr, flush=True) - response = input().strip().lower() - action = ( - "accept" - if response in ("y", "yes") - else ("decline" if response in ("n", "no") else "cancel") - ) - return types.ElicitResult(action=action) - - # Form mode: collect structured JSON input - print("Please provide response as JSON:", file=sys.stderr) - if params.requestedSchema: - schema_json = anyenv.dump_json(params.requestedSchema, indent=True) - print(f"Expected schema:\n{schema_json}", file=sys.stderr) - print("> ", end="", file=sys.stderr, flush=True) - response = input() - try: - content = anyenv.load_json(response, return_type=dict) - return types.ElicitResult(action="accept", content=content) - except anyenv.JsonLoadError as e: - return types.ErrorData(code=types.INVALID_REQUEST, message=f"Invalid JSON: {e}") + match params: + case types.ElicitRequestURLParams(url=url): + print(f"URL: {url}", file=sys.stderr) + print("Open this URL? [y/n]: ", end="", file=sys.stderr, flush=True) + response = input().strip().lower() + action = ( + "accept" + if response in ("y", "yes") + else ("decline" if response in ("n", "no") else "cancel") + ) + return types.ElicitResult(action=action) + case types.ElicitRequestFormParams(requestedSchema=schema): + # Form mode: collect structured JSON input + print("Please provide response as JSON:", file=sys.stderr) + if schema: + schema_json = anyenv.dump_json(schema, indent=True) + print(f"Expected schema:\n{schema_json}", file=sys.stderr) + print("> ", end="", file=sys.stderr, flush=True) + response = input() + try: + content = anyenv.load_json(response, return_type=dict) + return types.ElicitResult(action="accept", content=content) + except anyenv.JsonLoadError as e: + return types.ErrorData( + code=types.INVALID_REQUEST, message=f"Invalid JSON: {e}" + ) except KeyboardInterrupt: return types.ElicitResult(action="cancel") diff --git a/src/agentpool/utils/__init__.py b/src/agentpool/utils/__init__.py index 0cadfef6d..7b63ffd29 100644 --- a/src/agentpool/utils/__init__.py +++ b/src/agentpool/utils/__init__.py @@ -30,7 +30,7 @@ def setup_env(env: jinja2.Environment) -> None: pydantic_playground_url, ) - env.globals |= dict(agent=Agent) + env.globals |= dict(agent=Agent) # ty: ignore[unsupported-operator] env.filters |= { "run_agent": run_agent, "run_agent_sync": run_agent_sync, diff --git a/src/agentpool/utils/baseregistry.py b/src/agentpool/utils/baseregistry.py index 6408777a6..bc30ea247 100644 --- a/src/agentpool/utils/baseregistry.py +++ b/src/agentpool/utils/baseregistry.py @@ -4,20 +4,17 @@ from abc import ABC, abstractmethod from collections.abc import MutableMapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from psygnal.containers import EventedDict if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterator, Sequence + from collections.abc import Iterator, Sequence from psygnal.containers import DictEvents -TKey = TypeVar("TKey", str, int) - - class AgentPoolError(Exception): """Base exception for all AgentPool errors.""" @@ -75,11 +72,9 @@ def events(self) -> DictEvents: def register(self, key: TKey, item: TItem | Any, replace: bool = False) -> None: """Register an item.""" if key in self._items and not replace: - msg = f"Item already registered: {key}" - raise self._error_class(msg) + raise self._error_class(f"Item already registered: {key}") - validated_item = self._validate_item(item) - self._items[key] = validated_item + self._items[key] = self._validate_item(item) def get(self, key: TKey) -> TItem: # type: ignore """Get an item by key.""" @@ -99,36 +94,14 @@ async def startup(self) -> None: """Initialize all registered items.""" if self._initialized: return - - try: - for item in self._items.values(): - await self._initialize_item(item) - self._initialized = True - except Exception as exc: - await self.shutdown() - msg = f"Registry startup failed: {exc}" - raise self._error_class(msg) from exc + self._initialized = True async def shutdown(self) -> None: """Cleanup all registered items.""" if not self._initialized: return - - errors: list[tuple[TKey, Exception]] = [] - - for key, item in self._items.items(): - try: - await self._cleanup_item(item) - except Exception as exc: # noqa: BLE001 - errors.append((key, exc)) - self._initialized = False - if errors: - error_msgs = [f"{key}: {exc}" for key, exc in errors] - msg = f"Errors during shutdown: {', '.join(error_msgs)}" - raise self._error_class(msg) - @property def _error_class(self) -> type[AgentPoolError]: """Error class to use for this registry.""" @@ -138,16 +111,6 @@ def _error_class(self) -> type[AgentPoolError]: def _validate_item(self, item: Any) -> TItem: """Validate and possibly transform item before registration.""" - async def _initialize_item(self, item: TItem) -> None: - """Initialize an item during startup.""" - if hasattr(item, "startup") and callable(item.startup): # pyright: ignore - await item.startup() # pyright: ignore # ty: ignore - - async def _cleanup_item(self, item: TItem) -> None: - """Clean up an item during shutdown.""" - if hasattr(item, "shutdown") and callable(item.shutdown): # pyright: ignore - await item.shutdown() # pyright: ignore # ty: ignore - # Implementing MutableMapping methods def __getitem__(self, key: TKey) -> TItem: try: @@ -172,12 +135,5 @@ def __delitem__(self, key: TKey) -> None: def __iter__(self) -> Iterator[TKey]: return iter(self._items) - async def __aiter__(self) -> AsyncIterator[tuple[TKey, TItem]]: - """Async iterate over items, ensuring they're initialized.""" - if not self._initialized: - await self.startup() - for key, item in self._items.items(): - yield key, item - def __len__(self) -> int: return len(self._items) diff --git a/src/agentpool/utils/count_tokens.py b/src/agentpool/utils/count_tokens.py index 1bca8c33c..0166e93ce 100644 --- a/src/agentpool/utils/count_tokens.py +++ b/src/agentpool/utils/count_tokens.py @@ -2,21 +2,10 @@ from __future__ import annotations -from functools import lru_cache from importlib.util import find_spec -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from collections.abc import Sequence - -DEFAULT_TOKEN_MODEL = "gpt-3.5-turbo" - - -@lru_cache -def has_tiktoken() -> bool: - """Check if tiktoken is available.""" - return bool(find_spec("tiktoken")) +DEFAULT_TOKEN_MODEL = "o4-mini" def count_tokens(text: str, model: str | None = None) -> int: @@ -29,34 +18,9 @@ def count_tokens(text: str, model: str | None = None) -> int: Returns: Estimated token count """ - if has_tiktoken(): + if find_spec("tiktoken"): import tiktoken encoding = tiktoken.encoding_for_model(model or DEFAULT_TOKEN_MODEL) return len(encoding.encode(text)) - - # Fallback: very rough approximation - # Strategies could be: - # 1. ~4 chars per token (quick but rough) - # 2. Word count * 1.3 (better for English) - # 3. Split on common token boundaries return len(text.split()) + len(text) // 4 - - -def batch_count_tokens(texts: Sequence[str], model: str | None = None) -> list[int]: - """Count tokens for multiple texts. - - Args: - texts: Sequence of texts to count - model: Optional model name for tiktoken - - Returns: - List of token counts - """ - if has_tiktoken(): - import tiktoken - - encoding = tiktoken.encoding_for_model(model or DEFAULT_TOKEN_MODEL) - return [len(encoding.encode(text)) for text in texts] - - return [count_tokens(text) for text in texts] diff --git a/src/agentpool/utils/streams.py b/src/agentpool/utils/file_ops_tracker.py similarity index 68% rename from src/agentpool/utils/streams.py rename to src/agentpool/utils/file_ops_tracker.py index 8b27f93e0..921b0ccfe 100644 --- a/src/agentpool/utils/streams.py +++ b/src/agentpool/utils/file_ops_tracker.py @@ -1,122 +1,13 @@ -"""Stream utilities for merging async iterators.""" - from __future__ import annotations -import asyncio -from contextlib import asynccontextmanager from dataclasses import dataclass, field import time -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal FileOperation = Literal["create", "write", "edit", "delete"] -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - -@asynccontextmanager -async def merge_queue_into_iterator[T, V]( # noqa: PLR0915 - primary_stream: AsyncIterator[T], - secondary_queue: asyncio.Queue[V], -) -> AsyncIterator[AsyncIterator[T | V]]: - """Merge a primary async stream with events from a secondary queue. - - Args: - primary_stream: The main async iterator (e.g., provider events) - secondary_queue: Queue containing secondary events (e.g., progress events) - - Yields: - Async iterator that yields events from both sources in real-time. - Secondary queue is fully drained before the iterator completes. - - Example: - ```python - progress_queue: asyncio.Queue[ProgressEvent] = asyncio.Queue() - - async with merge_queue_into_iterator(provider_stream, progress_queue) as events: - async for event in events: - print(f"Got event: {event}") - ``` - """ - # Create a queue for all merged events - event_queue: asyncio.Queue[V | T | None] = asyncio.Queue() - primary_done = asyncio.Event() - primary_exception: BaseException | None = None - # Track if we've signaled the end of streams - end_signaled = False - - # Task to read from primary stream and put into merged queue - async def primary_task() -> None: - nonlocal primary_exception, end_signaled - try: - async for event in primary_stream: - await event_queue.put(event) - except asyncio.CancelledError: - # Signal completion and unblock merged_events before re-raising - primary_done.set() - if not end_signaled: - end_signaled = True - await event_queue.put(None) - raise - except BaseException as e: # noqa: BLE001 - primary_exception = e - finally: - primary_done.set() - - # Task to read from secondary queue and put into merged queue - async def secondary_task() -> None: - nonlocal end_signaled - try: - while not primary_done.is_set(): - try: - secondary_event = await asyncio.wait_for(secondary_queue.get(), timeout=0.01) - await event_queue.put(secondary_event) - except TimeoutError: - continue - # Drain any remaining events after primary completes - while not secondary_queue.empty(): - try: - secondary_event = secondary_queue.get_nowait() - await event_queue.put(secondary_event) - except asyncio.QueueEmpty: - break - # Now signal end of all events (only if not already signaled) - if not end_signaled: - end_signaled = True - await event_queue.put(None) - except asyncio.CancelledError: - # Still need to signal completion on cancel (only if not already signaled) - if not end_signaled: - end_signaled = True - await event_queue.put(None) - - # Start both tasks - primary_task_obj = asyncio.create_task(primary_task()) - secondary_task_obj = asyncio.create_task(secondary_task()) - - try: - # Create async iterator that drains the merged queue - async def merged_events() -> AsyncIterator[V | T]: - while True: - event = await event_queue.get() - if event is None: # End of all streams - break - yield event - # Re-raise any exception from primary stream after draining - if primary_exception is not None: - raise primary_exception - - yield merged_events() - - finally: - # Clean up tasks - cancel BOTH tasks - primary_task_obj.cancel() - secondary_task_obj.cancel() - await asyncio.gather(primary_task_obj, secondary_task_obj, return_exceptions=True) - - @dataclass class FileChange: """Represents a single file change operation.""" diff --git a/src/agentpool/utils/identifiers.py b/src/agentpool/utils/identifiers.py index d5a058275..9810f63d5 100644 --- a/src/agentpool/utils/identifiers.py +++ b/src/agentpool/utils/identifiers.py @@ -13,7 +13,7 @@ from typing import Literal -PrefixType = Literal["session", "message", "permission", "user", "part", "pty", "call"] +PrefixType = Literal["session", "message", "permission", "user", "part", "pty", "call", "workspace"] PREFIXES: dict[PrefixType, str] = { "session": "ses", @@ -23,6 +23,7 @@ "part": "prt", "pty": "pty", "call": "cal", + "workspace": "wsp", } BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" @@ -51,25 +52,15 @@ def ascending(prefix: PrefixType, given: str | None = None) -> str: Raises: ValueError: If given ID doesn't start with expected prefix """ - if given is not None: - expected_prefix = PREFIXES[prefix] - if not given.startswith(expected_prefix): - msg = f"ID {given} does not start with {expected_prefix}" - raise ValueError(msg) - return given - - return _create(prefix, descending=False) + if given is None: + return _create(prefix, descending=False) + if not given.startswith(expected_prefix := PREFIXES[prefix]): + raise ValueError(f"ID {given} does not start with {expected_prefix}") + return given def descending(prefix: PrefixType) -> str: - """Generate a descending (reverse chronologically sortable) ID. - - Args: - prefix: The type prefix for the ID - - Returns: - A reverse-sortable ID - """ + """Generate a descending (reverse chronologically sortable) ID.""" return _create(prefix, descending=True) @@ -86,7 +77,6 @@ def _create(prefix: PrefixType, *, descending: bool = False) -> str: global _last_timestamp, _counter # noqa: PLW0603 current_timestamp = int(time.time() * 1000) # milliseconds - if current_timestamp != _last_timestamp: _last_timestamp = current_timestamp _counter = 0 @@ -94,7 +84,6 @@ def _create(prefix: PrefixType, *, descending: bool = False) -> str: # Combine timestamp and counter now = current_timestamp * 0x1000 + _counter - if descending: now = ~now & 0xFFFFFFFFFFFF # Invert for descending order (48 bits) @@ -104,19 +93,11 @@ def _create(prefix: PrefixType, *, descending: bool = False) -> str: time_bytes[i] = (now >> (40 - 8 * i)) & 0xFF time_hex = time_bytes.hex() - # Add random suffix (14 chars for 26 total after prefix) random_suffix = _random_base62(ID_LENGTH - 12) - return f"{PREFIXES[prefix]}_{time_hex}{random_suffix}" def generate_session_id() -> str: - """Generate a unique, chronologically sortable session ID. - - Convenience function for the common case. - - Returns: - A session ID like 'ses_b71310fdf001ZHcn6VSpkaBcHi' - """ + """Generate a unique, chronologically sortable session ID ('ses_b71310fdf0...').""" return ascending("session") diff --git a/src/agentpool/utils/parse_time.py b/src/agentpool/utils/parse_time.py deleted file mode 100644 index 9bb092839..000000000 --- a/src/agentpool/utils/parse_time.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Time period parsing for CLI and API interfaces.""" - -from __future__ import annotations - -from datetime import timedelta -import re - - -# Time units with their patterns -_WEEKS = r"(?P[\d.]+)\s*(?:w|wks?|weeks?)" -_DAYS = r"(?P[\d.]+)\s*(?:d|dys?|days?)" -_HOURS = r"(?P[\d.]+)\s*(?:h|hrs?|hours?)" -_MINS = r"(?P[\d.]+)\s*(?:m|mins?|minutes?)" -_SECS = r"(?P[\d.]+)\s*(?:s|secs?|seconds?)" - -# Separators between units -_SEPARATORS = r"[,/]" - - -# Optional patterns with separators -def _OPT(x: str) -> str: # noqa: N802 - return f"(?:{x})?" - - -def _OPTSEP(x: str) -> str: # noqa: N802 - return f"(?:{x}\\s*(?:{_SEPARATORS}\\s*)?)?" - - -# All supported time formats -_TIME_FORMAT = f"{_OPTSEP(_WEEKS)}{_OPTSEP(_DAYS)}{_OPTSEP(_HOURS)}{_OPTSEP(_MINS)}{_OPT(_SECS)}" - -# Time unit multipliers in seconds -_MULTIPLIERS = { - "weeks": 60 * 60 * 24 * 7, - "days": 60 * 60 * 24, - "hours": 60 * 60, - "mins": 60, - "secs": 1, -} - -# Compile patterns -_SIGN_PATTERN = re.compile(r"\s*(?P[+|-])?\s*(?P.*$)") -_TIME_PATTERN = re.compile(rf"\s*{_TIME_FORMAT}\s*$", re.IGNORECASE) - - -def parse_time_period(period: str) -> timedelta: - """Parse a time expression into a timedelta. - - Examples: - - Simple format: 1h, 2d, 1w - - Full words: 1 hour, 2 days, 1 week - - Combined: 1 week 2 days 3 hours - - With separators: 1h, 30m - - Signed: -1h, +2d - - Decimal values: 1.5h - - Args: - period: Time period string to parse - - Raises: - ValueError: If the time format is invalid - - Returns: - Parsed time period as timedelta - """ - # Handle sign - sign_match = _SIGN_PATTERN.match(period) - if not sign_match: - raise ValueError(f"Invalid time format: {period}") - - sign = -1 if sign_match.group("sign") == "-" else 1 - unsigned = sign_match.group("unsigned") - - # Match time pattern - if match := _TIME_PATTERN.match(unsigned): - dct = match.groupdict() - matches = {k: v for k, v in dct.items() if v is not None} - try: - secs = sum(_MULTIPLIERS[unit] * float(val) for unit, val in matches.items()) - return timedelta(seconds=sign * secs) - except (ValueError, KeyError) as e: - raise ValueError(f"Invalid time value in: {period}") from e - - raise ValueError(f"Unsupported time format: {period}") diff --git a/src/agentpool/utils/pydantic_ai_helpers.py b/src/agentpool/utils/pydantic_ai_helpers.py index eae7d4f88..a1ca0d645 100644 --- a/src/agentpool/utils/pydantic_ai_helpers.py +++ b/src/agentpool/utils/pydantic_ai_helpers.py @@ -5,7 +5,17 @@ from typing import TYPE_CHECKING, Any from urllib.parse import unquote, urlparse -from pydantic_ai import AudioUrl, BinaryContent, DocumentUrl, ImageUrl, VideoUrl +from pydantic_ai import ( + AudioUrl, + BinaryContent, + BuiltinToolCallPart, + BuiltinToolReturnPart, + DocumentUrl, + ImageUrl, + RequestUsage, + RunUsage, + VideoUrl, +) from pydantic_ai.messages import BaseToolCallPart from agentpool.common_types import PathReference @@ -13,8 +23,73 @@ if TYPE_CHECKING: from fsspec.asyn import AsyncFileSystem - from pydantic_ai import FileUrl, MultiModalContent, UserContent - from pydantic_ai.messages import ToolCallPartDelta + from mcp.types import ToolAnnotations + from pydantic_ai import ( + FileUrl, + MultiModalContent, + ToolCallPartDelta, + UserContent, + ) + + +def get_builtin_tool_annotations(kind: str) -> ToolAnnotations: + """Return MCP ToolAnnotations for a pydantic-ai builtin tool kind. + + Args: + kind: The builtin tool kind string (e.g. 'web_search', 'code_execution'). + + Returns: + ToolAnnotations with appropriate hints for the tool kind. + Unknown kinds return annotations with only a title set. + """ + from mcp.types import ToolAnnotations + + title = kind.replace("_", " ").title() + annotations: dict[str, ToolAnnotations] = { + "web_search": ToolAnnotations( + title=title, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + "web_fetch": ToolAnnotations( + title=title, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, + ), + "code_execution": ToolAnnotations( + title=title, + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ), + "image_generation": ToolAnnotations( + title=title, + readOnlyHint=False, + destructiveHint=False, + idempotentHint=False, + openWorldHint=True, + ), + "memory": ToolAnnotations( + title=title, + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + "file_search": ToolAnnotations( + title=title, + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + } + return annotations.get(kind, ToolAnnotations(title=title)) def safe_args_as_dict( @@ -198,3 +273,35 @@ def uri_to_path_reference( return None name = format_uri_as_link(uri) return PathReference(path=path, fs=fs, mime_type=mime_type, display_name=name) + + +def to_request_usage(usage: RunUsage) -> RequestUsage: + return RequestUsage( + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + cache_write_tokens=usage.cache_write_tokens, + cache_read_tokens=usage.cache_read_tokens, + input_audio_tokens=usage.input_audio_tokens, + cache_audio_read_tokens=usage.cache_audio_read_tokens, + output_audio_tokens=usage.output_audio_tokens, + ) + + +def to_run_usage(usage: RequestUsage) -> RunUsage: + return RunUsage( + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + cache_write_tokens=usage.cache_write_tokens, + cache_read_tokens=usage.cache_read_tokens, + input_audio_tokens=usage.input_audio_tokens, + cache_audio_read_tokens=usage.cache_audio_read_tokens, + output_audio_tokens=usage.output_audio_tokens, + ) + + +def get_builtin_tool_parts( + tool_name: str, tc_id: str, args: dict[str, Any], content: str +) -> tuple[BuiltinToolCallPart, BuiltinToolReturnPart]: + call = BuiltinToolCallPart(tool_name=tool_name, args=args, tool_call_id=tc_id) + return_part = BuiltinToolReturnPart(tool_name=tool_name, content=content, tool_call_id=tc_id) + return (call, return_part) diff --git a/src/agentpool/utils/signatures.py b/src/agentpool/utils/signatures.py index ea905cf15..e6b348d5e 100644 --- a/src/agentpool/utils/signatures.py +++ b/src/agentpool/utils/signatures.py @@ -14,6 +14,8 @@ if TYPE_CHECKING: from collections.abc import Callable + from schemez.functionschema import ToolParameters + logger = get_logger(__name__) @@ -117,7 +119,7 @@ def get_params_matching_predicate( return {name for name, param in sig.parameters.items() if predicate(param)} -def filter_schema_params(schema: dict[str, Any], params_to_remove: set[str]) -> dict[str, Any]: +def filter_schema_params(schema: ToolParameters, params_to_remove: set[str]) -> ToolParameters: """Filter parameters from a JSON schema. Creates a copy of the schema with specified parameters removed from @@ -133,7 +135,7 @@ def filter_schema_params(schema: dict[str, Any], params_to_remove: set[str]) -> if not params_to_remove: return schema - result = schema.copy() + result: ToolParameters = schema.copy() if "properties" in result: result["properties"] = { k: v for k, v in result["properties"].items() if k not in params_to_remove @@ -287,9 +289,9 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: # Preserve introspection attributes wrapper.__name__ = getattr(original_callable, "__name__", "wrapper") wrapper.__doc__ = getattr(original_callable, "__doc__", None) - wrapper.__module__ = getattr(original_callable, "__module__", None) # type: ignore[assignment] - wrapper.__wrapped__ = original_callable # type: ignore[attr-defined] - wrapper.__agentpool_wrapped__ = original_callable # type: ignore[attr-defined] + wrapper.__module__ = getattr(original_callable, "__module__", None) # type: ignore[assignment] # ty:ignore[invalid-assignment] + wrapper.__wrapped__ = original_callable # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + wrapper.__agentpool_wrapped__ = original_callable # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # Create modified signature without context parameters try: @@ -303,7 +305,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if i not in context_positions and param.name not in bound_kwarg_names ] new_sig = sig.replace(parameters=new_params) - wrapper.__signature__ = new_sig # type: ignore[attr-defined] + wrapper.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] wrapper.__annotations__ = { name: param.annotation for name, param in new_sig.parameters.items() } diff --git a/src/agentpool/utils/streams/__init__.py b/src/agentpool/utils/streams/__init__.py new file mode 100644 index 000000000..f32c2ad1d --- /dev/null +++ b/src/agentpool/utils/streams/__init__.py @@ -0,0 +1,5 @@ +"""Stream utilities.""" + +from .helpers import merge_queue_into_iterator + +__all__ = ["merge_queue_into_iterator"] diff --git a/src/agentpool/utils/streams/helpers.py b/src/agentpool/utils/streams/helpers.py new file mode 100644 index 000000000..fde1992ff --- /dev/null +++ b/src/agentpool/utils/streams/helpers.py @@ -0,0 +1,112 @@ +"""Stream utilities for merging async iterators.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from collections.abc import AsyncIterable, AsyncIterator + + +@asynccontextmanager +async def merge_queue_into_iterator[T, V]( # noqa: PLR0915 + primary_stream: AsyncIterable[T], + secondary_queue: asyncio.Queue[V], +) -> AsyncIterator[AsyncIterator[T | V]]: + """Merge a primary async stream with events from a secondary queue. + + Args: + primary_stream: The main async iterator (e.g., provider events) + secondary_queue: Queue containing secondary events (e.g., progress events) + + Yields: + Async iterator that yields events from both sources in real-time. + Secondary queue is fully drained before the iterator completes. + + Example: + ```python + progress_queue: asyncio.Queue[ProgressEvent] = asyncio.Queue() + + async with merge_queue_into_iterator(provider_stream, progress_queue) as events: + async for event in events: + print(f"Got event: {event}") + ``` + """ + # Create a queue for all merged events + event_queue: asyncio.Queue[V | T | None] = asyncio.Queue() + primary_done = asyncio.Event() + primary_exception: BaseException | None = None + # Track if we've signaled the end of streams + end_signaled = False + + # Task to read from primary stream and put into merged queue + async def primary_task() -> None: + nonlocal primary_exception, end_signaled + try: + async for event in primary_stream: + await event_queue.put(event) + except asyncio.CancelledError: + # Signal completion and unblock merged_events before re-raising + primary_done.set() + if not end_signaled: + end_signaled = True + await event_queue.put(None) + raise + except BaseException as e: # noqa: BLE001 + primary_exception = e + finally: + primary_done.set() + + # Task to read from secondary queue and put into merged queue + async def secondary_task() -> None: + nonlocal end_signaled + try: + while not primary_done.is_set(): + try: + secondary_event = await asyncio.wait_for(secondary_queue.get(), timeout=0.01) + await event_queue.put(secondary_event) + except TimeoutError: + continue + # Drain any remaining events after primary completes + while not secondary_queue.empty(): + try: + secondary_event = secondary_queue.get_nowait() + await event_queue.put(secondary_event) + except asyncio.QueueEmpty: + break + # Now signal end of all events (only if not already signaled) + if not end_signaled: + end_signaled = True + await event_queue.put(None) + except asyncio.CancelledError: + # Still need to signal completion on cancel (only if not already signaled) + if not end_signaled: + end_signaled = True + await event_queue.put(None) + + # Start both tasks + primary_task_obj = asyncio.create_task(primary_task()) + secondary_task_obj = asyncio.create_task(secondary_task()) + + try: + # Create async iterator that drains the merged queue + async def merged_events() -> AsyncIterator[V | T]: + while True: + event = await event_queue.get() + if event is None: # End of all streams + break + yield event + # Re-raise any exception from primary stream after draining + if primary_exception is not None: + raise primary_exception + + yield merged_events() + + finally: + # Clean up tasks - cancel BOTH tasks + primary_task_obj.cancel() + secondary_task_obj.cancel() + await asyncio.gather(primary_task_obj, secondary_task_obj, return_exceptions=True) diff --git a/src/agentpool/utils/streams/streamed_response.py b/src/agentpool/utils/streams/streamed_response.py new file mode 100644 index 000000000..8b804dc0b --- /dev/null +++ b/src/agentpool/utils/streams/streamed_response.py @@ -0,0 +1,87 @@ +"""Convert between Codex and AgentPool types. + +Provides converters for: +- Event conversion (Codex streaming events -> AgentPool events) +- MCP server configs (Native configs -> Codex types) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from pydantic_ai import ModelResponse, RequestUsage +from pydantic_ai._parts_manager import ModelResponsePartsManager + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from datetime import datetime + + from pydantic_ai import FinishReason + + from agentpool.agents.events import RichAgentStreamEvent + + +@dataclass(kw_only=True) +class StreamedResponse(ABC): + """Streamed response from an LLM when calling a tool.""" + + provider_response_id: str | None = field(default=None, init=False) + provider_details: dict[str, Any] | None = field(default=None, init=False) + finish_reason: FinishReason | None = field(default=None, init=False) + _parts_manager: ModelResponsePartsManager = field( + default_factory=ModelResponsePartsManager, init=False + ) + _usage: RequestUsage = field(default_factory=RequestUsage, init=False) + + def __aiter__(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: + """Stream the response as an async iterable of [`RichAgentStreamEvent`].""" + return self._get_event_iterator() + + @abstractmethod + async def _get_event_iterator(self) -> AsyncIterator[RichAgentStreamEvent[Any]]: + """Return an async iterator of RichAgentStreamEvents. + + This method should be implemented by subclasses to translate the vendor-specific stream + of events into agentpool-format events. + + It should use the `_parts_manager` to handle deltas, and should update the + `_usage` attributes as it goes. + """ + raise NotImplementedError + # noinspection PyUnreachableCode + yield + + def get(self) -> ModelResponse: + """Build a ModelResponse from the data received from the stream so far.""" + return ModelResponse( + parts=self._parts_manager.get_parts(), + model_name=self.model_name, + timestamp=self.timestamp, + usage=self.usage(), + provider_response_id=self.provider_response_id, + provider_details=self.provider_details, + finish_reason=self.finish_reason, + ) + + # TODO (v2): Make this a property + def usage(self) -> RequestUsage: + """Get the usage of the response so far. + + This will not be the final usage until the stream is exhausted. + """ + return self._usage + + @property + @abstractmethod + def model_name(self) -> str: + """Get the model name of the response.""" + raise NotImplementedError + + @property + @abstractmethod + def timestamp(self) -> datetime: + """Get the timestamp of the response.""" + raise NotImplementedError diff --git a/src/agentpool/utils/thread_helpers.py b/src/agentpool/utils/thread_helpers.py index 34b095bc2..c40aa4bd3 100644 --- a/src/agentpool/utils/thread_helpers.py +++ b/src/agentpool/utils/thread_helpers.py @@ -151,22 +151,6 @@ async def limited(coro: Awaitable[T]) -> T: return list(await asyncio.gather(*[limited(c) for c in coros_list])) -def parallel_if_free_threaded[**P, R]( - func: Callable[P, R], -) -> Callable[P, R]: - """Decorator that marks a function for potential parallelization. - - This is a no-op decorator that serves as documentation and could be - extended in the future to automatically parallelize marked functions. - - Currently just returns the function unchanged but indicates the function - is safe for parallel execution on free-threaded builds. - """ - # For now, just mark it - could be extended later - func._parallel_safe = True # type: ignore[attr-defined] - return func - - def run_in_thread[**P, R]( func: Callable[P, R], ) -> Callable[P, Awaitable[R]]: @@ -193,7 +177,6 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: "FREE_THREADED", "async_parallel_map", "gather_with_concurrency", - "parallel_if_free_threaded", "parallel_map", "parallel_starmap", "run_in_thread", diff --git a/src/agentpool/utils/time_utils.py b/src/agentpool/utils/time_utils.py index 61c5cd416..a11ceaaab 100644 --- a/src/agentpool/utils/time_utils.py +++ b/src/agentpool/utils/time_utils.py @@ -2,7 +2,8 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta +import re import time from typing import Literal @@ -10,6 +11,43 @@ TimeZoneMode = Literal["utc", "local"] +# Time units with their patterns +_WEEKS = r"(?P[\d.]+)\s*(?:w|wks?|weeks?)" +_DAYS = r"(?P[\d.]+)\s*(?:d|dys?|days?)" +_HOURS = r"(?P[\d.]+)\s*(?:h|hrs?|hours?)" +_MINS = r"(?P[\d.]+)\s*(?:m|mins?|minutes?)" +_SECS = r"(?P[\d.]+)\s*(?:s|secs?|seconds?)" + +# Separators between units +_SEPARATORS = r"[,/]" + + +# Optional patterns with separators +def _OPT(x: str) -> str: # noqa: N802 + return f"(?:{x})?" + + +def _OPTSEP(x: str) -> str: # noqa: N802 + return f"(?:{x}\\s*(?:{_SEPARATORS}\\s*)?)?" + + +# All supported time formats +_TIME_FORMAT = f"{_OPTSEP(_WEEKS)}{_OPTSEP(_DAYS)}{_OPTSEP(_HOURS)}{_OPTSEP(_MINS)}{_OPT(_SECS)}" + +# Time unit multipliers in seconds +_MULTIPLIERS = { + "weeks": 60 * 60 * 24 * 7, + "days": 60 * 60 * 24, + "hours": 60 * 60, + "mins": 60, + "secs": 1, +} + +# Compile patterns +_SIGN_PATTERN = re.compile(r"\s*(?P[+|-])?\s*(?P.*$)") +_TIME_PATTERN = re.compile(rf"\s*{_TIME_FORMAT}\s*$", re.IGNORECASE) + + def get_now(tz_mode: TimeZoneMode = "utc") -> datetime: """Get current datetime in UTC or local timezone.""" now = datetime.now(UTC) @@ -47,3 +85,44 @@ def parse_iso_timestamp(value: str, *, fallback: datetime | None = None) -> date return datetime.fromisoformat(value.replace("Z", "+00:00")) except (ValueError, AttributeError): return fallback if fallback is not None else get_now() + + +def parse_time_period(period: str) -> timedelta: + """Parse a time expression into a timedelta. + + Examples: + - Simple format: 1h, 2d, 1w + - Full words: 1 hour, 2 days, 1 week + - Combined: 1 week 2 days 3 hours + - With separators: 1h, 30m + - Signed: -1h, +2d + - Decimal values: 1.5h + + Args: + period: Time period string to parse + + Raises: + ValueError: If the time format is invalid + + Returns: + Parsed time period as timedelta + """ + # Handle sign + sign_match = _SIGN_PATTERN.match(period) + if not sign_match: + raise ValueError(f"Invalid time format: {period}") + + sign = -1 if sign_match.group("sign") == "-" else 1 + unsigned = sign_match.group("unsigned") + + # Match time pattern + if match := _TIME_PATTERN.match(unsigned): + dct = match.groupdict() + matches = {k: v for k, v in dct.items() if v is not None} + try: + secs = sum(_MULTIPLIERS[unit] * float(val) for unit, val in matches.items()) + return timedelta(seconds=sign * secs) + except (ValueError, KeyError) as e: + raise ValueError(f"Invalid time value in: {period}") from e + + raise ValueError(f"Unsupported time format: {period}") diff --git a/src/agentpool/utils/todos.py b/src/agentpool/utils/todos.py index 495b01afd..c04109ef1 100644 --- a/src/agentpool/utils/todos.py +++ b/src/agentpool/utils/todos.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass, field +import time from typing import TYPE_CHECKING, Any, Literal @@ -15,9 +16,6 @@ TodoPriority = Literal["high", "medium", "low"] TodoStatus = Literal["pending", "in_progress", "completed"] -# Keep old names as aliases -PlanEntryPriority = TodoPriority -PlanEntryStatus = TodoStatus STATUS_ICONS = {"pending": "⬚", "in_progress": "◐", "completed": "✓"} PRIORITY_LABELS = {"high": "🔴", "medium": "🟡", "low": "🟢"} @@ -48,7 +46,7 @@ class TodoEntry(PlanEntry): id: str """Unique identifier for this entry.""" - created_at: float = field(default_factory=lambda: __import__("time").time()) + created_at: float = field(default_factory=time.time) """Unix timestamp when the entry was created.""" def to_dict(self) -> dict[str, Any]: @@ -221,10 +219,7 @@ def clear(self) -> None: self.entries.clear() self._notify_change() - def replace_all( - self, - entries: Sequence[PlanEntry], - ) -> None: + def replace_all(self, entries: Sequence[PlanEntry]) -> None: """Replace all entries with new ones (single notification). More efficient than clear() + multiple add() calls since it only @@ -235,9 +230,8 @@ def replace_all( """ self.entries.clear() for entry in entries: - id_ = self._next_id() todo = TodoEntry( - id=id_, + id=self._next_id(), content=entry.content, priority=entry.priority, status=entry.status, diff --git a/src/agentpool/utils/token_breakdown.py b/src/agentpool/utils/token_breakdown.py index f20d6efeb..4960cc7ca 100644 --- a/src/agentpool/utils/token_breakdown.py +++ b/src/agentpool/utils/token_breakdown.py @@ -7,28 +7,36 @@ from typing import TYPE_CHECKING, Any import anyenv -from pydantic_ai.messages import ( +from pydantic_ai import ( ModelRequest, ModelResponse, + RunUsage, SystemPromptPart, ThinkingPart, ToolCallPart, + ToolDefinition, ) from pydantic_ai.models import ModelRequestParameters -from pydantic_ai.tools import ToolDefinition -from pydantic_ai.usage import RequestUsage, RunUsage if TYPE_CHECKING: from collections.abc import Sequence - from pydantic_ai.messages import ModelMessage, TextPart + from pydantic_ai import ( + ModelMessage, + ModelResponsePart, + ModelSettings, + TextPart, + UserContent, + ) from pydantic_ai.models import Model - from pydantic_ai.settings import ModelSettings from agentpool.messaging.messages import TokenCost +DEFAULT_ENCODING_MODEL = "gpt-4" + + @dataclass class TokenUsage: """Single item's token count.""" @@ -82,7 +90,7 @@ def _normalize_tool_schema(tool: ToolDefinition | dict[str, Any]) -> dict[str, A return tool -def count_tokens(text: str, model_name: str = "gpt-4") -> int: +def count_tokens(text: str, model_name: str | None = None) -> int: """Count tokens using tiktoken. Args: @@ -99,7 +107,7 @@ def count_tokens(text: str, model_name: str = "gpt-4") -> int: return len(text) // 4 try: - encoding = tiktoken.encoding_for_model(model_name) + encoding = tiktoken.encoding_for_model(model_name or DEFAULT_ENCODING_MODEL) except KeyError: # Fall back to cl100k_base for unknown models encoding = tiktoken.get_encoding("cl100k_base") @@ -108,12 +116,12 @@ def count_tokens(text: str, model_name: str = "gpt-4") -> int: async def calculate_usage_from_parts( - input_parts: Sequence[Any], - response_parts: Sequence[TextPart | ThinkingPart | ToolCallPart], + input_parts: Sequence[UserContent], + response_parts: Sequence[ModelResponsePart], text_content: str, model_name: str | None = None, provider: str | None = None, -) -> tuple[RequestUsage, TokenCost | None]: +) -> tuple[RunUsage, TokenCost | None]: """Calculate token usage and cost from input/output parts. This is used by agents that don't receive usage info from the backend @@ -127,15 +135,13 @@ async def calculate_usage_from_parts( provider: Provider name for cost calculation Returns: - Tuple of (RequestUsage, TokenCost or None) + Tuple of (RunUsage, TokenCost or None) """ from agentpool.messaging.messages import TokenCost - model_for_count = model_name or "gpt-4" - # Input tokens from prompts input_text = " ".join(str(p) for p in input_parts) - input_tokens = count_tokens(input_text, model_for_count) + input_tokens = count_tokens(input_text, model_name) # Output tokens from response content output_text = text_content @@ -145,10 +151,10 @@ async def calculate_usage_from_parts( elif isinstance(part, ToolCallPart) and part.args: args_str = anyenv.dump_json(part.args) if not isinstance(part.args, str) else part.args output_text += args_str - output_tokens = count_tokens(output_text, model_for_count) + output_tokens = count_tokens(output_text, model_name) # Build usage - usage = RequestUsage(input_tokens=input_tokens, output_tokens=output_tokens) + usage = RunUsage(input_tokens=input_tokens, output_tokens=output_tokens) run_usage = RunUsage(input_tokens=input_tokens, output_tokens=output_tokens) # Calculate cost @@ -342,18 +348,18 @@ def format_breakdown(breakdown: TokenBreakdown, detailed: bool = False) -> str: if __name__ == "__main__": import asyncio - from pydantic_ai.messages import ( + from pydantic_ai import ( ImageUrl, ModelRequest, ModelResponse, SystemPromptPart, TextPart, ToolCallPart, + ToolDefinition, ToolReturnPart, UserPromptPart, ) from pydantic_ai.models.test import TestModel - from pydantic_ai.tools import ToolDefinition async def main() -> None: # Create sample tool definitions diff --git a/src/agentpool_bot/channels/slack.py b/src/agentpool_bot/channels/slack.py index c6f9cde30..a91e9e025 100644 --- a/src/agentpool_bot/channels/slack.py +++ b/src/agentpool_bot/channels/slack.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: + from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from agentpool_bot.bus import MessageBus, OutboundMessage @@ -50,7 +51,7 @@ async def start(self) -> None: self._running = True self._web_client = AsyncWebClient(token=self.config.bot_token) self._socket_client = SocketModeClient(self.config.app_token, web_client=self._web_client) - self._socket_client.socket_mode_request_listeners.append(self._on_socket_request) # type: ignore[arg-type] + self._socket_client.socket_mode_request_listeners.append(self._on_socket_request) # Resolve bot user ID for mention handling try: @@ -97,7 +98,7 @@ async def send(self, msg: OutboundMessage) -> None: async def _on_socket_request( # noqa: PLR0911 self, - client: SocketModeClient, + client: AsyncBaseSocketModeClient, req: SocketModeRequest, ) -> None: """Handle incoming Socket Mode requests.""" diff --git a/src/agentpool_cli/history.py b/src/agentpool_cli/history.py index 274f85c6c..21b4c0c0f 100644 --- a/src/agentpool_cli/history.py +++ b/src/agentpool_cli/history.py @@ -137,7 +137,7 @@ def show_stats( """ import anyio - from agentpool.utils.parse_time import parse_time_period + from agentpool.utils.time_utils import parse_time_period from agentpool_storage.formatters import format_stats from agentpool_storage.models import StatsFilters diff --git a/src/agentpool_cli/serve_acp.py b/src/agentpool_cli/serve_acp.py index 5cfc09418..7d4e82079 100644 --- a/src/agentpool_cli/serve_acp.py +++ b/src/agentpool_cli/serve_acp.py @@ -143,10 +143,7 @@ def acp_command( # noqa: PLR0915 # Resolve configuration from all layers # fallback_config is only used if no agents are defined in any layer try: - resolved = resolve_config( - explicit_path=config, - fallback_config=ACP_ASSISTANT, - ) + resolved = resolve_config(explicit_path=config, fallback_config=ACP_ASSISTANT) except ValueError as e: raise t.BadParameter(str(e)) from e diff --git a/src/agentpool_cli/serve_api.py b/src/agentpool_cli/serve_api.py index b42625d77..6aeb66006 100644 --- a/src/agentpool_cli/serve_api.py +++ b/src/agentpool_cli/serve_api.py @@ -24,15 +24,18 @@ def api_command( host: Annotated[str, t.Option(help="Host to bind server to")] = "localhost", port: Annotated[int, t.Option(help="Port to listen on")] = 8000, cors: Annotated[bool, t.Option(help="Enable CORS")] = True, + agent: Annotated[ + str | None, + t.Option("--agent", help="Name of specific agent to use (defaults to pool's default)"), + ] = None, show_messages: Annotated[ bool, t.Option("--show-messages", help="Show message activity") ] = False, - docs: Annotated[bool, t.Option(help="Enable API documentation")] = True, ) -> None: - """Run agents as a completions API server. + """Run an agent as an OpenAI-compatible completions API server. - This creates an OpenAI-compatible API server that makes your agents available - through a standard completions API interface. + This creates an OpenAI-compatible API server backed by a single agent. + Model listing uses the agent's model discovery. """ import uvicorn @@ -50,13 +53,12 @@ def on_message(message: ChatMessage[Any]) -> None: msg = str(e) raise t.BadParameter(msg) from e manifest = AgentsManifest.from_file(config_path) - pool = AgentPool(manifest) + pool = AgentPool(manifest, main_agent_name=agent) if show_messages: - for agent in pool.all_agents.values(): - agent.message_sent.connect(on_message) + pool.main_agent.message_sent.connect(on_message) - server = OpenAIAPIServer(pool, cors=cors, docs=docs) + server = OpenAIAPIServer(pool, cors=cors) # Get log level from the global context log_level = ctx.obj.get("log_level", "info") if ctx.obj else "info" diff --git a/src/agentpool_cli/serve_vercel.py b/src/agentpool_cli/serve_vercel.py index a2cbee932..9c8482040 100644 --- a/src/agentpool_cli/serve_vercel.py +++ b/src/agentpool_cli/serve_vercel.py @@ -93,7 +93,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: if cors: app.add_middleware( - CORSMiddleware, # ty: ignore[invalid-argument-type] + CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], diff --git a/src/agentpool_commands/models.py b/src/agentpool_commands/models.py index c6896d505..9cc315710 100644 --- a/src/agentpool_commands/models.py +++ b/src/agentpool_commands/models.py @@ -29,10 +29,7 @@ class ListModelsCommand(NodeCommand): name = "list-models" category = "model" - async def execute_command( - self, - ctx: CommandContext[AgentContext], - ) -> None: + async def execute_command(self, ctx: CommandContext[AgentContext]) -> None: """List available models. Args: @@ -52,7 +49,7 @@ async def execute_command( ] for model in models: - model_id = model.id_override if model.id_override else model.id + model_id = model.id_override or model.id name = model.name or "" description = model.description or "" # Escape pipe characters in fields @@ -86,11 +83,7 @@ class SetModelCommand(NodeCommand): name = "set-model" category = "model" - async def execute_command( - self, - ctx: CommandContext[AgentContext], - model: str, - ) -> None: + async def execute_command(self, ctx: CommandContext[AgentContext], model: str) -> None: """Change the model for the current conversation. Args: diff --git a/src/agentpool_commands/pool.py b/src/agentpool_commands/pool.py index 49c8fce82..7e39053ac 100644 --- a/src/agentpool_commands/pool.py +++ b/src/agentpool_commands/pool.py @@ -112,6 +112,12 @@ async def execute_command( preset: Optional preset name (minimal, balanced, summarizing) """ from agentpool.agents.base_agent import BaseAgent + from agentpool.messaging.compaction import ( + balanced_context, + compact_conversation, + minimal_context, + summarizing_context, + ) # Get agent from context agent = ctx.context.node @@ -127,17 +133,8 @@ async def execute_command( return try: - # Get compaction pipeline - from agentpool.messaging.compaction import ( - balanced_context, - minimal_context, - summarizing_context, - ) - pipeline = None - - # Check for preset override - if preset: + if preset: # Check for preset override match preset.lower(): case "minimal": pipeline = minimal_context() @@ -161,10 +158,7 @@ async def execute_command( pipeline = summarizing_context() await ctx.output.print("🔄 **Compacting conversation history...**") - # Apply the pipeline using shared helper - from agentpool.messaging.compaction import compact_conversation - original_count, compacted_count = await compact_conversation( pipeline, agent.conversation ) @@ -239,7 +233,6 @@ async def execute_command( # The event handler system (ACP, OpenCode, CLI, etc.) handles rendering # Get parent agent's context to access event emitter parent_ctx = ctx.context.agent.get_context() - async for event in agent.run_stream(task_prompt): wrapped = SubAgentEvent( source_name=agent_name, diff --git a/src/agentpool_commands/text_sharing/__init__.py b/src/agentpool_commands/text_sharing/__init__.py index 8fbb4104b..ec00d64e1 100644 --- a/src/agentpool_commands/text_sharing/__init__.py +++ b/src/agentpool_commands/text_sharing/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal, assert_never, overload +from typing import Any, Literal, assert_never, overload from agentpool_commands.text_sharing.base import ShareResult, TextSharer, Visibility from agentpool_commands.text_sharing.github_gist import GistSharer @@ -53,10 +53,7 @@ def get_sharer( ) -> ShittyCodingAgentSharer: ... -def get_sharer( - provider: TextSharerStr, - **kwargs: str | None, -) -> TextSharer: +def get_sharer(provider: TextSharerStr, **kwargs: Any) -> TextSharer: """Get a text sharer based on provider name. Args: @@ -98,7 +95,7 @@ def get_sharer( case "paste_rs": return PasteRsSharer() case "opencode": - return OpenCodeSharer(**kwargs) # type: ignore[arg-type] + return OpenCodeSharer(**kwargs) case "shittycodingagent": return ShittyCodingAgentSharer(**kwargs) case _ as unreachable: diff --git a/src/agentpool_commands/tools.py b/src/agentpool_commands/tools.py index 6d670a65f..0aa7dfc7e 100644 --- a/src/agentpool_commands/tools.py +++ b/src/agentpool_commands/tools.py @@ -237,9 +237,7 @@ async def execute_command( source="dynamic", metadata={"import_path": import_path, "registered_via": "register-tool"}, ) - # Show the registered tool info - tool_info.format_info() await ctx.print( f"✅ **Tool registered successfully:**\n`{tool_info.name}`" f" - {tool_info.description or '*No description*'}" diff --git a/src/agentpool_config/conditions.py b/src/agentpool_config/conditions.py index a3a33b764..73f07f553 100644 --- a/src/agentpool_config/conditions.py +++ b/src/agentpool_config/conditions.py @@ -177,16 +177,13 @@ class TokenThresholdCondition(ConnectionCondition): async def check(self, context: EventContext[Any]) -> bool: """Check if token threshold is reached.""" - if not context.message.cost_info: - return False - match self.count_type: case "total": return context.stats.token_count >= self.max_tokens case "prompt": - return context.message.cost_info.token_usage.input_tokens >= self.max_tokens + return context.message.usage.input_tokens >= self.max_tokens case "completion": - return context.message.cost_info.token_usage.output_tokens >= self.max_tokens + return context.message.usage.output_tokens >= self.max_tokens case _ as unreachable: assert_never(unreachable) diff --git a/src/agentpool_config/durable.py b/src/agentpool_config/durable.py index c41f61663..82999b48d 100644 --- a/src/agentpool_config/durable.py +++ b/src/agentpool_config/durable.py @@ -8,7 +8,7 @@ from pydantic import ConfigDict, Field, field_validator from schemez import Schema -from agentpool.utils.parse_time import parse_time_period +from agentpool.utils.time_utils import parse_time_period class BaseDurableExecutionConfig(Schema): diff --git a/src/agentpool_config/session.py b/src/agentpool_config/session.py index 83d08fcde..a73d10186 100644 --- a/src/agentpool_config/session.py +++ b/src/agentpool_config/session.py @@ -169,8 +169,7 @@ class SessionQuery(Schema): def get_time_cutoff(self) -> datetime | None: """Get datetime from time period string.""" - from agentpool.utils.parse_time import parse_time_period - from agentpool.utils.time_utils import get_now + from agentpool.utils.time_utils import get_now, parse_time_period if not self.since: return None diff --git a/src/agentpool_config/tools.py b/src/agentpool_config/tools.py index cac4d6f72..df33f1049 100644 --- a/src/agentpool_config/tools.py +++ b/src/agentpool_config/tools.py @@ -3,13 +3,15 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, Self from pydantic import ConfigDict, Field, ImportString from schemez import Schema if TYPE_CHECKING: + from mcp.types import ToolAnnotations + from agentpool.tools.base import Tool @@ -29,6 +31,16 @@ class ToolHints(Schema): """Hints that this tool can access / interact with external resources beyond the current system""" + @classmethod + def from_mcp(cls, annotations: ToolAnnotations) -> Self: + """Create a ToolHints instance from MCP tool annotations.""" + return cls( + read_only=annotations.readOnlyHint, + destructive=annotations.destructiveHint, + idempotent=annotations.idempotentHint, + open_world=annotations.openWorldHint, + ) + class BaseToolConfig(Schema): """Base configuration for agent tools.""" diff --git a/src/agentpool_prompts/langfuse_hub.py b/src/agentpool_prompts/langfuse_hub.py index 75b4d3ef5..440c5ea9a 100644 --- a/src/agentpool_prompts/langfuse_hub.py +++ b/src/agentpool_prompts/langfuse_hub.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any -from langfuse import Langfuse # pyright: ignore +from langfuse import Langfuse from agentpool.prompts.base import BasePromptProvider @@ -21,14 +21,13 @@ class LangfusePromptHub(BasePromptProvider): supports_variables = True def __init__(self, config: LangfuseConfig) -> None: + from langfuse.api.client import LangfuseAPI + self.config = config secret = config.secret_key.get_secret_value() pub = config.public_key.get_secret_value() self._client = Langfuse(secret_key=secret, public_key=pub, host=str(config.host)) - - from langfuse.api.client import FernLangfuse - - self._api_client = FernLangfuse( + self._api_client = LangfuseAPI( base_url=str(config.host), x_langfuse_public_key=pub, username=pub, diff --git a/src/agentpool_prompts/promptlayer_provider.py b/src/agentpool_prompts/promptlayer_provider.py index 4f1b030d0..50c088a7c 100644 --- a/src/agentpool_prompts/promptlayer_provider.py +++ b/src/agentpool_prompts/promptlayer_provider.py @@ -20,9 +20,8 @@ class PromptLayerProvider(BasePromptProvider): supports_versions = True def __init__(self, config: PromptLayerConfig) -> None: - self.client = PromptLayer( - api_key=config.api_key.get_secret_value() if config.api_key else None - ) + key = config.api_key.get_secret_value() if config.api_key else None + self.client = PromptLayer(api_key=key) # ty:ignore[invalid-argument-type] # pyright: ignore[reportArgumentType] async def get_prompt( self, diff --git a/src/agentpool_server/a2a_server/agent_worker.py b/src/agentpool_server/a2a_server/agent_worker.py index 427f0e678..f17560113 100644 --- a/src/agentpool_server/a2a_server/agent_worker.py +++ b/src/agentpool_server/a2a_server/agent_worker.py @@ -224,8 +224,6 @@ def _request_parts_from_a2a(parts: list[Part]) -> list[ModelRequestPart]: model_parts.append(UserPromptPart(content=[content])) case {"kind": "data"}: raise NotImplementedError("Data parts are not supported yet.") - case _: - assert_never(part) # ty: ignore[type-assertion-failure] return model_parts @@ -272,13 +270,12 @@ def _response_parts_to_a2a(parts: Sequence[ModelResponsePart]) -> list[Part]: """ a2a_parts: list[Part] = [] for part in parts: - if isinstance(part, TextPart): - a2a_parts.append(A2ATextPart(kind="text", text=part.content)) - elif isinstance(part, ThinkingPart): - # Convert thinking to text with metadata - meta = {"type": "thinking", "thinking_id": part.id, "signature": part.signature} - a2a_parts.append(A2ATextPart(kind="text", text=part.content, metadata=meta)) - elif isinstance(part, ToolCallPart): - # Skip tool calls - they're internal to agent execution - pass + match part: + case TextPart(content=content): + a2a_parts.append(A2ATextPart(kind="text", text=content)) + case ThinkingPart(content=content, id=thinking_id, signature=signature): + meta = {"type": "thinking", "thinking_id": thinking_id, "signature": signature} + a2a_parts.append(A2ATextPart(kind="text", text=content, metadata=meta)) + case ToolCallPart(): + pass return a2a_parts diff --git a/src/agentpool_server/acp_server/acp_agent.py b/src/agentpool_server/acp_server/acp_agent.py index 698e970a2..32d390cd3 100644 --- a/src/agentpool_server/acp_server/acp_agent.py +++ b/src/agentpool_server/acp_server/acp_agent.py @@ -4,10 +4,11 @@ from dataclasses import KW_ONLY, dataclass, field from importlib.metadata import version as _version -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, assert_never from acp import Agent as ACPAgent from acp.schema import ( + CloseSessionResponse, ForkSessionResponse, InitializeResponse, ListSessionsResponse, @@ -16,15 +17,16 @@ NewSessionResponse, PromptResponse, ResumeSessionResponse, + SessionInfoUpdate, SessionMode, SessionModelState, SessionModeState, + SessionNotification, SetSessionConfigOptionResponse, SetSessionModelRequest, SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, - StopSessionResponse, ) from agentpool.log import get_logger from agentpool.utils.tasks import TaskManager @@ -40,11 +42,13 @@ AuthenticateRequest, CancelNotification, ClientCapabilities, + CloseSessionRequest, ForkSessionRequest, Implementation, InitializeRequest, ListSessionsRequest, LoadSessionRequest, + LogoutRequest, NewSessionRequest, PromptRequest, ResumeSessionRequest, @@ -52,7 +56,6 @@ SetSessionConfigOptionRequest, SetSessionModelRequest, SetSessionModeRequest, - StopSessionRequest, ) from agentpool import AgentPool from agentpool.agents.base_agent import BaseAgent @@ -130,7 +133,8 @@ async def get_session_mode_state(agent: BaseAgent) -> SessionModeState | None: if not category: return None acp_modes = [ # Convert ModeInfo to ACP SessionMode - SessionMode(id=mode.id, name=mode.name, description=mode.description) + # TODO: remove legacy stuff + SessionMode(id=str(mode.value), name=mode.name, description=mode.description) for mode in category.available_modes ] return SessionModeState(available_modes=acp_modes, current_mode_id=category.current_mode_id) @@ -185,22 +189,14 @@ def __post_init__(self) -> None: """Initialize derived attributes and setup after field assignment.""" self.client_capabilities: ClientCapabilities | None = None self.client_info: Implementation | None = None - pool = self.agent_pool - if pool is None: - msg = "Default agent has no associated pool" - raise RuntimeError(msg) - self.session_manager = ACPSessionManager(pool=pool) + self.session_manager = ACPSessionManager(pool=self.agent_pool) self.tasks = TaskManager() self._initialized = False - self._sessions_cache: ListSessionsResponse | None = None - self._sessions_cache_time: float = 0.0 # Connect to title generation signal to notify clients of session updates - pool.storage.metadata_generated.connect(self._on_metadata_generated) + self.agent_pool.storage.metadata_generated.connect(self._on_metadata_generated) async def _on_metadata_generated(self, event: SessionMetadataGeneratedEvent) -> None: """Handle metadata generation - notify active sessions of the update.""" - from acp.schema import SessionInfoUpdate, SessionNotification - session = self.session_manager.get_session(event.session_id) if session is None: logger.debug("Metadata generated for inactive session", session_id=event.session_id) @@ -210,7 +206,7 @@ async def _on_metadata_generated(self, event: SessionMetadataGeneratedEvent) -> update = SessionInfoUpdate(session_id=event.session_id, title=event.metadata.title) notification = SessionNotification(session_id=event.session_id, update=update) try: - await session.client.session_update(notification) # pyright: ignore[reportArgumentType] + await session.client.session_update(notification) logger.info( "Sent session info update", session_id=event.session_id, @@ -220,8 +216,9 @@ async def _on_metadata_generated(self, event: SessionMetadataGeneratedEvent) -> logger.exception("Failed to send session info update", session_id=event.session_id) @property - def agent_pool(self) -> AgentPool[Any] | None: + def agent_pool(self) -> AgentPool[Any]: """Get the agent pool from the default agent.""" + assert self.default_agent.agent_pool return self.default_agent.agent_pool # Note: Tool registration happens after initialize() when we know client caps @@ -241,7 +238,7 @@ async def initialize(self, params: InitializeRequest) -> InitializeResponse: load_session=True, list_sessions=True, resume_session=True, - stop_session=True, + close_session=True, http_mcp_servers=True, sse_mcp_servers=True, audio_prompts=True, @@ -313,6 +310,7 @@ async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: Then replays the conversation to the client via ACP notifications. """ from agentpool.agents.acp_agent import ACPAgent as ACPAgentClient + from agentpool.agents.acp_agent.acp_converters import model_messages_to_session_updates if not self._initialized: raise RuntimeError("Agent not initialized") @@ -348,7 +346,8 @@ async def load_session(self, params: LoadSessionRequest) -> LoadSessionResponse: for chat_msg in msgs: if chat_msg.messages: model_messages.extend(chat_msg.messages) - await session.notifications.replay(model_messages) + for update in model_messages_to_session_updates(model_messages): + await session.notifications.send_update(update) logger.info( "Conversation replayed", session_id=params.session_id, @@ -378,18 +377,9 @@ async def list_sessions(self, params: ListSessionsRequest) -> ListSessionsRespon Uses a short TTL cache to avoid redundant expensive storage reads when clients request the list multiple times in quick succession. """ - import time - if not self._initialized: raise RuntimeError("Agent not initialized") - # Return cached result if fresh (within 10 seconds) - cache_ttl = 10.0 - now = time.monotonic() - if self._sessions_cache and (now - self._sessions_cache_time) < cache_ttl: - logger.debug("Returning cached sessions list", count=len(self._sessions_cache.sessions)) - return self._sessions_cache - # Get agent from first active session, or fall back to default first_session = next(iter(self.session_manager._active.values()), None) agent = first_session.agent if first_session else self.default_agent @@ -398,16 +388,12 @@ async def list_sessions(self, params: ListSessionsRequest) -> ListSessionsRespon agent_sessions = await agent.list_sessions() logger.info("Agent returned sessions", count=len(agent_sessions)) sessions = [to_session_info(s) for s in agent_sessions] - logger.info("Listed sessions", count=len(sessions)) - response = ListSessionsResponse(sessions=sessions) except Exception: logger.exception("Failed to list sessions") return ListSessionsResponse(sessions=[]) else: - # Cache the result - self._sessions_cache = response - self._sessions_cache_time = now - return response + logger.info("Listed sessions", count=len(sessions)) + return ListSessionsResponse(sessions=sessions) async def fork_session(self, params: ForkSessionRequest) -> ForkSessionResponse: """Fork an existing session. @@ -483,6 +469,10 @@ async def authenticate(self, params: AuthenticateRequest) -> None: """Authenticate with the agent.""" logger.info("Authentication requested", method_id=params.method_id) + async def logout(self, params: LogoutRequest) -> None: + """Log out of the current authenticated state.""" + logger.info("Logout requested") + async def prompt(self, params: PromptRequest) -> PromptResponse: """Process a prompt request.""" if not self._initialized: @@ -544,8 +534,8 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: logger.info("Returning PromptResponse", stop_reason=stop_reason) return response - async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: - """Stop an active session and free its resources. + async def close_session(self, params: CloseSessionRequest) -> CloseSessionResponse: + """Close an active session and free its resources. Cancels any ongoing work (like session/cancel) and then closes the session and releases all associated resources. @@ -559,8 +549,8 @@ async def stop_session(self, params: StopSessionRequest) -> StopSessionResponse: await self.session_manager.close_session(params.session_id) logger.info("Session stopped", session_id=params.session_id) except Exception: - logger.exception("Failed to stop session", session_id=params.session_id) - return StopSessionResponse() + logger.exception("Failed to close session", session_id=params.session_id) + return CloseSessionResponse() async def cancel(self, params: CancelNotification) -> None: """Cancel operations for a session.""" @@ -664,9 +654,16 @@ async def set_session_config_option( session_id=params.session_id, ) try: - # Forward to agent's set_mode method - # config_id maps to category_id, value maps to mode_id - await session.agent.set_mode(params.value, category_id=params.config_id) + match params.value: + case bool(): + # Boolean config option — forward as string mode_id + bool_str = str(params.value).lower() + await session.agent.set_mode(bool_str, category_id=params.config_id) + case str(): + # Select config option — config_id maps to category_id, value to mode_id + await session.agent.set_mode(params.value, category_id=params.config_id) + case _ as unreachable: + assert_never(unreachable) # Return updated config options config_options = await get_session_config_options(session.agent) return SetSessionConfigOptionResponse(config_options=config_options) @@ -706,9 +703,7 @@ async def swap_pool(self, config_path: str, agent_name: str | None = None) -> li # 3. Update internal references self.default_agent = new_agent pool = new_agent.agent_pool - if pool is None: - msg = "New agent has no associated pool" - raise RuntimeError(msg) + assert pool self.session_manager._pool = pool agent_names = list(pool.all_agents.keys()) logger.info("Pool swap complete", agent_names=agent_names) diff --git a/src/agentpool_server/acp_server/commands/debug_commands.py b/src/agentpool_server/acp_server/commands/debug_commands.py index f72234cdd..598dbeedc 100644 --- a/src/agentpool_server/acp_server/commands/debug_commands.py +++ b/src/agentpool_server/acp_server/commands/debug_commands.py @@ -202,7 +202,7 @@ async def execute_command( try: # Auto-construct the correct SessionUpdate type via discriminator update = SessionUpdateAdapter.validate_python(notification_data) - await session.notifications.send_update(update) # pyright: ignore[reportArgumentType] + await session.notifications.send_update(update) count += 1 if delay_ms: await anyio.sleep(delay_ms / 1000) diff --git a/src/agentpool_server/acp_server/converters.py b/src/agentpool_server/acp_server/converters.py index 2d156e38f..bad625226 100644 --- a/src/agentpool_server/acp_server/converters.py +++ b/src/agentpool_server/acp_server/converters.py @@ -27,6 +27,7 @@ StdioMcpServer, TextContentBlock, TextResourceContents, + Usage, ) from agentpool.log import get_logger from agentpool.utils.pydantic_ai_helpers import ( @@ -44,8 +45,9 @@ if TYPE_CHECKING: from fsspec.asyn import AsyncFileSystem from pydantic_ai import UserContent + from pydantic_ai.usage import UsageBase - from acp.schema import ContentBlock, McpServer, SessionConfigOption + from acp.schema import ContentBlock, McpServer, SelectSessionConfigOption from acp.schema.content_blocks import ResourceContents from agentpool.agents.modes import ModeCategory, ModeInfo from agentpool.common_types import PathReference @@ -169,13 +171,15 @@ def resource_to_content(resource: ResourceContents) -> str | BinaryImage | Binar def to_session_select_option(mode: ModeInfo) -> SessionConfigSelectOption: - return SessionConfigSelectOption(value=mode.id, name=mode.name, description=mode.description) + return SessionConfigSelectOption( + value=str(mode.value), name=mode.name, description=mode.description + ) -def to_session_config_option(category: ModeCategory) -> SessionConfigOption: - from acp.schema import SessionConfigOption +def to_session_config_option(category: ModeCategory) -> SelectSessionConfigOption: + from acp.schema import SelectSessionConfigOption - return SessionConfigOption( + return SelectSessionConfigOption( id=category.id, name=category.name, description=None, @@ -198,3 +202,14 @@ def agent_to_mode(agent: MessageNode[Any, Any]) -> SessionMode: """Convert agent to a session mode.""" desc = agent.description or f"Switch to {agent.name} agent" return SessionMode(id=agent.name, name=agent.display_name, description=desc) + + +def to_usage(usage: UsageBase) -> Usage: + return Usage( + total_tokens=usage.total_tokens, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + thought_tokens=usage.details.get("reasoning_tokens") or None, + cached_read_tokens=usage.cache_read_tokens or None, + cached_write_tokens=usage.cache_write_tokens or None, + ) diff --git a/src/agentpool_server/acp_server/event_converter.py b/src/agentpool_server/acp_server/event_converter.py index dcd119c97..540b05e26 100644 --- a/src/agentpool_server/acp_server/event_converter.py +++ b/src/agentpool_server/acp_server/event_converter.py @@ -28,6 +28,7 @@ TextPartDelta, ThinkingPart, ThinkingPartDelta, + ToolCallPart, ToolCallPartDelta, ToolReturnPart, ) @@ -45,7 +46,6 @@ ToolCallLocation, ToolCallProgress, ToolCallStart, - Usage, UsageUpdate, ) from acp.utils import generate_tool_title, infer_tool_kind, to_acp_content_blocks @@ -68,11 +68,13 @@ ) from agentpool.log import get_logger from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict +from agentpool_server.acp_server.converters import to_usage if TYPE_CHECKING: from collections.abc import AsyncIterator + from acp.schema import SessionUpdate, Usage from acp.schema.tool_call import ToolCallContent, ToolCallKind from agentpool.agents.events import RichAgentStreamEvent from agentpool.agents.events.events import SubAgentType @@ -80,17 +82,6 @@ logger = get_logger(__name__) -# Type alias for all session updates the converter can yield -ACPSessionUpdate = ( - AgentMessageChunk - | AgentThoughtChunk - | ToolCallStart - | ToolCallProgress - | AgentPlanUpdate - | UsageUpdate -) - - @dataclass class _ToolState: """Internal state for a single tool call.""" @@ -200,7 +191,7 @@ def _cleanup_tool_state(self, tool_call_id: str) -> None: async def convert( # noqa: PLR0915 self, event: RichAgentStreamEvent[Any] - ) -> AsyncIterator[ACPSessionUpdate]: + ) -> AsyncIterator[SessionUpdate]: """Convert an agent event to zero or more ACP session updates.""" from acp.schema import ( FileEditToolCallContent, @@ -282,11 +273,18 @@ async def convert( # noqa: PLR0915 ) # Function tool call started - case FunctionToolCallEvent(part=part): - tool_call_id = part.tool_call_id - tool_input = safe_args_as_dict(part, default={}) + case ( + FunctionToolCallEvent(part=ToolCallPart() as call_part) # type: ignore[misc] + | PartStartEvent(part=BuiltinToolCallPart() as call_part) + ): + tool_call_id = call_part.tool_call_id + tool_input = safe_args_as_dict(call_part, default={}) self._current_tool_inputs[tool_call_id] = tool_input - state = self._get_or_create_tool_state(tool_call_id, part.tool_name, tool_input) + state = self._get_or_create_tool_state( + tool_call_id, + call_part.tool_name, + tool_input, + ) if not state.started: state.started = True yield ToolCallStart( @@ -298,19 +296,20 @@ async def convert( # noqa: PLR0915 ) # Tool completed successfully - case FunctionToolResultEvent(result=ToolReturnPart(content=out), tool_call_id=tc_id): + case ( + FunctionToolResultEvent(result=ToolReturnPart(content=out), tool_call_id=tc_id) # type: ignore[misc] + | PartStartEvent(part=BuiltinToolReturnPart(content=out, tool_call_id=tc_id)) + ): # Handle async generator content - tool_state = self._tool_states.get(tc_id) - if tool_state and tool_state.has_content: + if (tool_state := self._tool_states.get(tc_id)) and tool_state.has_content: yield ToolCallProgress(tool_call_id=tc_id, status="completed", raw_output=out) else: converted = to_acp_content_blocks(out) - content_items = [ContentToolCallContent(content=block) for block in converted] yield ToolCallProgress( tool_call_id=tc_id, status="completed", raw_output=out, - content=content_items, + content=[ContentToolCallContent(content=block) for block in converted], ) self._cleanup_tool_state(tc_id) @@ -329,6 +328,7 @@ async def convert( # noqa: PLR0915 kind=kind, locations=loc_items, raw_input=raw_input, + field_meta=meta, ): state = self._get_or_create_tool_state(tc_id, tool_name, raw_input or {}) acp_locations = [ToolCallLocation(path=i.path, line=i.line) for i in loc_items] @@ -342,6 +342,7 @@ async def convert( # noqa: PLR0915 raw_input=raw_input, locations=acp_locations or None, status="pending", + field_meta=meta, ) else: # Send update with tool-provided details @@ -350,6 +351,7 @@ async def convert( # noqa: PLR0915 title=title, kind=kind, locations=acp_locations or None, + field_meta=meta, ) # Tool progress event - create state if needed (tool may emit progress before SDK event) @@ -361,6 +363,7 @@ async def convert( # noqa: PLR0915 progress=progress, total=total, message=message, + field_meta=meta, ) if tool_call_id: # Get or create state - handles race where tool emits before SDK event state = self._get_or_create_tool_state(tool_call_id, "unknown", {}) @@ -423,6 +426,7 @@ async def convert( # noqa: PLR0915 status="in_progress", content=acp_content or None, locations=locations or None, + field_meta=meta, ) if acp_content: state.has_content = True @@ -433,21 +437,10 @@ async def convert( # noqa: PLR0915 case StreamCompleteEvent(message=message): request_usage = message.usage if request_usage.total_tokens > 0: - thought = request_usage.details.get("reasoning_tokens") or None - self.last_usage = Usage( - total_tokens=request_usage.total_tokens, - input_tokens=request_usage.input_tokens, - output_tokens=request_usage.output_tokens, - thought_tokens=thought, - cached_read_tokens=request_usage.cache_read_tokens or None, - cached_write_tokens=request_usage.cache_write_tokens or None, - ) + self.last_usage = to_usage(request_usage) cost_obj: Cost | None = None if message.cost_info and message.cost_info.total_cost: - cost_obj = Cost( - amount=float(message.cost_info.total_cost), - currency="USD", - ) + cost_obj = Cost(amount=float(message.cost_info.total_cost), currency="USD") yield UsageUpdate( used=request_usage.total_tokens, size=request_usage.total_tokens, # best approximation @@ -497,7 +490,7 @@ async def _convert_subagent_inline( source_type: SubAgentType, inner_event: RichAgentStreamEvent[Any], depth: int, - ) -> AsyncIterator[ACPSessionUpdate]: + ) -> AsyncIterator[SessionUpdate]: """Convert subagent event to inline text notifications.""" indent = " " * depth icon = "🤖" if source_type == "agent" else "👥" @@ -523,12 +516,18 @@ async def _convert_subagent_inline( f"{indent}[{source_name}] {delta or ''}", message_id=self._current_message_id ) - case FunctionToolCallEvent(part=part): + case ( + FunctionToolCallEvent(part=part) + | PartStartEvent(part=BuiltinToolCallPart() as part) + ): text = f"\n{indent}🔧 [{source_name}] Using tool: {part.tool_name}\n" yield AgentMessageChunk.text(text, message_id=self._current_message_id) - case FunctionToolResultEvent( - result=ToolReturnPart(content=content, tool_name=tool_name), + case ( + FunctionToolResultEvent( + result=ToolReturnPart(content=content, tool_name=tool_name), + ) + | PartStartEvent(part=BuiltinToolReturnPart(content=content, tool_name=tool_name)) ): result_str = str(content) if len(result_str) > 200: # noqa: PLR2004 @@ -577,7 +576,7 @@ async def _convert_subagent_tool_box( source_type: SubAgentType, inner_event: RichAgentStreamEvent[Any], depth: int, - ) -> AsyncIterator[ACPSessionUpdate]: + ) -> AsyncIterator[SessionUpdate]: """Convert subagent event to tool box notifications.""" state_key = f"subagent:{source_name}:{depth}" icon = "🤖" if source_type == "agent" else "👥" @@ -605,13 +604,19 @@ async def _convert_subagent_tool_box( async for n in self._emit_subagent_progress(state_key, f"{icon} {source_name}"): yield n - case FunctionToolCallEvent(part=part): + case ( + FunctionToolCallEvent(part=part) + | PartStartEvent(part=BuiltinToolCallPart() as part) + ): accumulated.append(f"\n🔧 Using tool: {part.tool_name}\n") async for n in self._emit_subagent_progress(state_key, f"{icon} {source_name}"): yield n - case FunctionToolResultEvent( - result=ToolReturnPart(content=content, tool_name=tool_name), + case ( + FunctionToolResultEvent( + result=ToolReturnPart(content=content, tool_name=tool_name), + ) + | PartStartEvent(part=BuiltinToolReturnPart(content=content, tool_name=tool_name)) ): result_str = str(content) if len(result_str) > 200: # noqa: PLR2004 @@ -658,7 +663,7 @@ async def _convert_subagent_tool_box( async def _emit_subagent_progress( self, state_key: str, title: str - ) -> AsyncIterator[ACPSessionUpdate]: + ) -> AsyncIterator[SessionUpdate]: """Emit tool call notifications for subagent content.""" accumulated = self._subagent_content.get(state_key, []) content_text = "".join(accumulated) diff --git a/src/agentpool_server/acp_server/session.py b/src/agentpool_server/acp_server/session.py index 4366e2d7f..cb1facae0 100644 --- a/src/agentpool_server/acp_server/session.py +++ b/src/agentpool_server/acp_server/session.py @@ -9,7 +9,7 @@ import asyncio from dataclasses import dataclass, field import re -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, assert_never import anyio from exxec.acp_provider import ACPExecutionEnvironment @@ -201,7 +201,6 @@ def __post_init__(self) -> None: self.log = logger.bind(session_id=self.session_id) self._task_lock = asyncio.Lock() self._cancelled = False - self._current_converter: ACPEventConverter | None = None self.last_usage: Usage | None = None self.fs = ACPFileSystem(self.client, session_id=self.session_id) self.command_store = CommandStore(commands=get_all_commands()) @@ -219,7 +218,7 @@ def __post_init__(self) -> None: agent.env = self.acp_env if isinstance(agent, Agent): # TODO: need to inject this info for ACP agents, too. - agent.sys_prompts.prompts.append(self.get_cwd_context) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + agent.sys_prompts.prompts.append(self.get_cwd_context) if isinstance(agent, ACPAgent): async def permission_callback( @@ -247,22 +246,22 @@ async def _on_state_updated( self, state: ModeInfo | ModelInfo | AvailableCommandsUpdate | ConfigOptionChanged ) -> None: """Handle state update signal from agent - forward to ACP client.""" - from acp.schema import ( - AvailableCommandsUpdate, - ConfigOptionUpdate, - CurrentModelUpdate, - CurrentModeUpdate, - ) + from acp.schema import AvailableCommandsUpdate, ConfigOptionUpdate, CurrentModeUpdate from agentpool_server.acp_server.acp_agent import get_session_config_options - update: CurrentModeUpdate | CurrentModelUpdate | ConfigOptionUpdate + update: CurrentModeUpdate | ConfigOptionUpdate match state: - case ModeInfo(id=mode_id): + case ModeInfo(value=mode_id, category_id="mode"): + assert isinstance(mode_id, str) update = CurrentModeUpdate(current_mode_id=mode_id) self.log.debug("Forwarding mode change to client", mode_id=mode_id) case ModelInfo(id=model_id): - update = CurrentModelUpdate(current_model_id=model_id) - self.log.debug("Forwarding model change to client", model_id=model_id) + # Model changes go through ConfigOptionUpdate (model category) + config_options = await get_session_config_options(self.agent) + if opt := next((i for i in config_options if i.id == "model"), None): + opt.current_value = model_id + update = ConfigOptionUpdate(config_options=config_options) + self.log.debug("Forwarding model change as config update", model_id=model_id) case AvailableCommandsUpdate(available_commands=cmds): # Store remote commands and send merged list self._remote_commands = list(cmds) @@ -274,18 +273,20 @@ async def _on_state_updated( config_options = await get_session_config_options(self.agent) # Update the changed option's current_value if opt := next((i for i in config_options if i.id == config_id), None): + assert isinstance(value_id, str) opt.current_value = value_id # Convert our core type to ACP type with full config_options - update = ConfigOptionUpdate( - config_id=config_id, - value_id=value_id, - config_options=config_options, - ) + update = ConfigOptionUpdate(config_options=config_options) self.log.debug("Config option change", config_id=config_id, value_id=value_id) # For permissions, also send legacy CurrentModeUpdate (still needed) if config_id == "permissions": + assert isinstance(value_id, str) await self.notifications.update_session_mode(value_id) self.log.debug("Also sent legacy mode update", mode_id=value_id) + case ModeInfo(): + return + case _ as unreachable: + assert_never(unreachable) await self.notifications.send_update(update) async def initialize(self) -> None: @@ -409,7 +410,6 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe event_count = 0 # Create a new event converter for this prompt converter = ACPEventConverter(subagent_display_mode=self.subagent_display_mode) - self._current_converter = converter # Track for cancellation try: # Use the session's persistent input provider # Staged content is automatically injected by run_stream @@ -432,7 +432,6 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe # This is needed because even though send() awaits the write, the client # may process messages asynchronously or out of order. await anyio.sleep(0.05) - self._current_converter = None return "cancelled" event_count += 1 @@ -453,13 +452,11 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe # CRITICAL: Allow time for client to process tool completion notifications # before sending PromptResponse. See comment in cancellation branch above. await anyio.sleep(0.05) - self._current_converter = None return "cancelled" except UsageLimitExceeded as e: self.log.info("Usage limit exceeded", error=str(e)) return infer_stop_reason(str(e)) except Exception as e: - self._current_converter = None # Clear converter reference self.log.exception("Error during streaming") # Send error notification asynchronously to avoid blocking response coro = self._send_error_notification(f"❌ Agent error: {e}") @@ -468,7 +465,6 @@ async def process_prompt(self, content_blocks: Sequence[ContentBlock]) -> StopRe else: # Title generation is now handled automatically by log_session self.last_usage = converter.last_usage - self._current_converter = None # Clear converter reference return "end_turn" async def _send_error_notification(self, message: str) -> None: @@ -487,7 +483,7 @@ async def close(self) -> None: # Remove cwd context callable from all agents for agent in self.agent_pool.get_agents(Agent).values(): if self.get_cwd_context in agent.sys_prompts.prompts: - agent.sys_prompts.prompts.remove(self.get_cwd_context) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + agent.sys_prompts.prompts.remove(self.get_cwd_context) # Note: Individual agents are managed by the pool's lifecycle # The pool will handle agent cleanup when it's closed diff --git a/src/agentpool_server/acp_server/session_manager.py b/src/agentpool_server/acp_server/session_manager.py index 58f040abf..fbe8e9290 100644 --- a/src/agentpool_server/acp_server/session_manager.py +++ b/src/agentpool_server/acp_server/session_manager.py @@ -77,10 +77,12 @@ async def create_session( Returns: Session ID for the created session """ + from agentpool.utils.identifiers import generate_session_id + async with self._lock: # Generate session ID if not provided if session_id is None: - session_id = self.storage.generate_session_id() + session_id = generate_session_id() # Check for existing session if session_id in self._active: diff --git a/src/agentpool_server/agui_server/base_agent_adapter.py b/src/agentpool_server/agui_server/base_agent_adapter.py index 59bbfb410..b658543d2 100644 --- a/src/agentpool_server/agui_server/base_agent_adapter.py +++ b/src/agentpool_server/agui_server/base_agent_adapter.py @@ -122,7 +122,7 @@ async def run_stream(self) -> AsyncIterator[BaseEvent]: # Transform compatible events through AGUIEventStream # Our RichAgentStreamEvent is a superset - AGUIEventStream handles # the pydantic-ai compatible events and ignores unknown types - async for agui_event in event_stream.handle_event(agent_event): # type: ignore[arg-type] + async for agui_event in event_stream.handle_event(agent_event): # type: ignore[arg-type] # ty:ignore[invalid-argument-type] yield agui_event except Exception as e: # noqa: BLE001 diff --git a/src/agentpool_server/openai_api_server/completions/helpers.py b/src/agentpool_server/openai_api_server/completions/helpers.py index fb84eaa7d..d1e54f936 100644 --- a/src/agentpool_server/openai_api_server/completions/helpers.py +++ b/src/agentpool_server/openai_api_server/completions/helpers.py @@ -6,9 +6,22 @@ from typing import TYPE_CHECKING, Any import anyenv -from pydantic_ai import PartDeltaEvent, TextPartDelta +from pydantic_ai import ( + FunctionToolCallEvent, + FunctionToolResultEvent, + PartDeltaEvent, + PartStartEvent, + RetryPromptPart, + TextPart, + TextPartDelta, + ToolCallPart, + ToolReturnPart, +) +from pydantic_ai.messages import BuiltinToolCallPart, BuiltinToolReturnPart +from agentpool.agents.events import CompactionEvent, ToolCallStartEvent from agentpool.log import get_logger +from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict if TYPE_CHECKING: @@ -20,6 +33,21 @@ logger = get_logger(__name__) +def _format_tool_call(tool_name: str, args: dict[str, Any]) -> str: + """Format a tool call as readable text.""" + args_str = ", ".join(f"{k}={v!r}" for k, v in args.items()) if args else "" + return f"\n🔧 **{tool_name}**({args_str})\n" + + +def _format_tool_result(tool_name: str, content: Any, is_error: bool = False) -> str: + """Format a tool result as readable text.""" + result_str = str(content) + if len(result_str) > 500: # noqa: PLR2004 + result_str = result_str[:500] + "..." + icon = "❌" if is_error else "✅" + return f"{icon} {tool_name}: {result_str}\n\n" + + async def stream_response( agent: SupportsRunStream[Any], content: str, @@ -29,33 +57,73 @@ async def stream_response( response_id = f"chatcmpl-{int(time.time() * 1000)}" created = int(time.time()) + def _make_chunk(text: str) -> str: + chunk_data = { + "id": response_id, + "object": "chat.completion.chunk", + "created": created, + "model": request.model, + "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}], + } + return f"data: {anyenv.dump_json(chunk_data)}\n\n" + try: # First chunk with role - choice = {"index": 0, "delta": {"role": "assistant"}, "finish_reason": None} first_chunk = { "id": response_id, "object": "chat.completion.chunk", "created": created, "model": request.model, - "choices": [choice], + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], } yield f"data: {anyenv.dump_json(first_chunk)}\n\n" + async for event in agent.run_stream(content): match event: - case PartDeltaEvent(delta=TextPartDelta(content_delta=chunk)): - # Skip empty chunks - if not chunk: - continue - delta = {"content": chunk} - choice = {"index": 0, "delta": delta, "finish_reason": None} - chunk_data = { - "id": response_id, - "object": "chat.completion.chunk", - "created": created, - "model": request.model, - "choices": [choice], - } - yield f"data: {anyenv.dump_json(chunk_data)}\n\n" + # Text output + case PartStartEvent(part=TextPart(content=delta)) if delta: + yield _make_chunk(delta) + case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta: + yield _make_chunk(delta) + + # Tool call started (pydantic-ai function tools) + case FunctionToolCallEvent(part=ToolCallPart() as part): + args = safe_args_as_dict(part, default={}) + yield _make_chunk(_format_tool_call(part.tool_name, args)) + + # Tool call started (builtin tools) + case PartStartEvent(part=BuiltinToolCallPart() as part): + args = safe_args_as_dict(part, default={}) + yield _make_chunk(_format_tool_call(part.tool_name, args)) + + # Rich tool call start (custom events from our agents) + case ToolCallStartEvent(tool_name=name, title=title): + label = title or name + yield _make_chunk(f"\n🔧 **{label}**\n") + + # Tool completed successfully + case FunctionToolResultEvent( + result=ToolReturnPart(content=out, tool_name=name), + ): + yield _make_chunk(_format_tool_result(name, out)) + + # Builtin tool completed + case PartStartEvent( + part=BuiltinToolReturnPart(content=out, tool_name=name), + ): + yield _make_chunk(_format_tool_result(name, out)) + + # Tool failed with retry + case FunctionToolResultEvent(result=RetryPromptPart() as result): + error_msg = result.model_response() + yield _make_chunk( + _format_tool_result(result.tool_name or "unknown", error_msg, is_error=True) + ) + + # Compaction + case CompactionEvent(phase="starting"): + yield _make_chunk(event.format()) + final_chunk = { "id": response_id, "object": "chat.completion.chunk", @@ -68,14 +136,13 @@ async def stream_response( except Exception as e: logger.exception("Error during streaming response") - delta = {"content": f"Error: {e!s}"} - choice = {"index": 0, "delta": delta, "finish_reason": "error"} - error_chunk = { + yield _make_chunk(f"Error: {e!s}") + error_final = { "id": response_id, "object": "chat.completion.chunk", "created": created, "model": request.model, - "choices": [choice], + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], } - yield f"data: {anyenv.dump_json(error_chunk)}\n\n" + yield f"data: {anyenv.dump_json(error_final)}\n\n" yield "data: [DONE]\n\n" diff --git a/src/agentpool_server/openai_api_server/completions/models.py b/src/agentpool_server/openai_api_server/completions/models.py index 11cd14777..1ada87e21 100644 --- a/src/agentpool_server/openai_api_server/completions/models.py +++ b/src/agentpool_server/openai_api_server/completions/models.py @@ -1,60 +1,34 @@ -"""OpenAI-compatible API server for AgentPool.""" +"""OpenAI-compatible API models for AgentPool chat completions.""" from __future__ import annotations -from typing import Any, Literal, TypedDict +from typing import Any, Literal +from openai.types import Model +from openai.types.chat import ChatCompletionMessageToolCall +from openai.types.chat.chat_completion_message_function_tool_call import Function from pydantic import Field from schemez import Schema -from agentpool.log import get_logger +class OpenAIModelInfo(Model): + """Exended OpenAI model info format.""" -logger = get_logger(__name__) - - -class CompletionUsage(TypedDict): - """Token usage information.""" - - input_tokens: int - output_tokens: int - total_tokens: int - - -class OpenAIModelInfo(Schema): - """OpenAI model info format.""" - - id: str - object: str = "model" - owned_by: str = "agentpool" - created: int description: str | None = None permissions: list[str] = Field(default_factory=list) -class FunctionCall(Schema): - """Function call information.""" - - name: str - arguments: str - - -class ToolCall(Schema): - """Tool call information.""" - - id: str - type: str = "function" - function: FunctionCall - - class OpenAIMessage(Schema): - """OpenAI chat message format.""" + """OpenAI chat message format (for request input). + + Covers all roles in a single model for easy request parsing. + """ role: Literal["system", "user", "assistant", "tool", "function"] - content: str | None # Content can be null in function calls + content: str | None = None name: str | None = None - function_call: FunctionCall | None = None - tool_calls: list[ToolCall] | None = None + function_call: Function | None = None + tool_calls: list[ChatCompletionMessageToolCall] | None = None class ChatCompletionRequest(Schema): @@ -67,32 +41,3 @@ class ChatCompletionRequest(Schema): max_tokens: int | None = None tools: list[dict[str, Any]] | None = None tool_choice: str | None = Field(default="auto") - - -class Choice(Schema): - """Choice in a completion response.""" - - index: int = 0 - message: OpenAIMessage - finish_reason: str = "stop" - - -class ChatCompletionResponse(Schema): - """OpenAI chat completion response.""" - - id: str - object: str = "chat.completion" - created: int - model: str - choices: list[Choice] - usage: CompletionUsage | None = None - - -class ChatCompletionChunk(Schema): - """Chunk of a streaming chat completion.""" - - id: str - object: str = "chat.completion.chunk" - created: int - model: str - choices: list[dict[str, Any]] diff --git a/src/agentpool_server/openai_api_server/responses/helpers.py b/src/agentpool_server/openai_api_server/responses/helpers.py index 541c2589f..2a5b3b908 100644 --- a/src/agentpool_server/openai_api_server/responses/helpers.py +++ b/src/agentpool_server/openai_api_server/responses/helpers.py @@ -2,19 +2,23 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING, Any from uuid import uuid4 -from agentpool_server.openai_api_server.responses.models import ( +from openai.types.responses import ( Response, - ResponseMessage, + ResponseFunctionToolCall, + ResponseOutputMessage, ResponseOutputText, - ResponseToolCall, ResponseUsage, ) +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails if TYPE_CHECKING: + from openai.types.responses import ResponseOutputItem + from agentpool.agents.base_agent import BaseAgent from agentpool_server.openai_api_server.responses.models import ResponseRequest @@ -22,52 +26,56 @@ async def handle_request(request: ResponseRequest, agent: BaseAgent[Any, Any]) -> Response: from fastapi import HTTPException - match request.input: - case str(): - content = request.input - case list(): - # Get last text content from structured input - last = request.input[-1]["content"] - text_parts = [p["text"] for p in last if p["type"] == "input_text"] - content = "\n".join(text_parts) - case _: - raise HTTPException(400, "Invalid input format") + from agentpool_server.openai_api_server.responses.models import extract_user_content + + try: + content_parts = extract_user_content(request) + except ValueError as e: + raise HTTPException(400, str(e)) from e - message = await agent.run(content) - text = ResponseOutputText(text=str(message.content)) + message = await agent.run(*content_parts) + text = ResponseOutputText(text=str(message.content), annotations=[], type="output_text") output_msg_id = f"msg_{uuid4().hex}" - output_msg = ResponseMessage(id=output_msg_id, role="assistant", content=[text]) - output: list[ResponseMessage | ResponseToolCall] = [output_msg] + output_msg = ResponseOutputMessage( + id=output_msg_id, + role="assistant", + content=[text], + status="completed", + type="message", + ) calls = [ - ResponseToolCall(type=f"{tc.tool_name}_call", id=tc.tool_call_id) + ResponseFunctionToolCall( + type="function_call", + arguments=str(tc.args), + call_id=tc.tool_call_id, + name=tc.tool_name, + ) for tc in message.get_tool_calls() ] - output = calls + output # type: ignore[assignment, operator] + output: list[ResponseOutputItem] = [*calls, output_msg] - usage_info: ResponseUsage | None = None - if message.cost_info and (token_usage := message.cost_info.token_usage): - # Map the keys correctly from agent's dict to ResponseUsage TypedDict - input_tk = token_usage.input_tokens - output_tk = token_usage.output_tokens - total_tk = token_usage.total_tokens - - usage_info = ResponseUsage( - input_tokens=input_tk, - input_tokens_details={}, - output_tokens=output_tk, - output_tokens_details={}, - total_tokens=total_tk, - ) + usage_info = ResponseUsage( + input_tokens=message.usage.input_tokens, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens=message.usage.output_tokens, + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + total_tokens=message.usage.total_tokens, + ) return Response( + id=f"resp_{uuid4().hex}", + created_at=int(datetime.now().timestamp()), model=request.model, + object="response", output=output, + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + status="completed", instructions=request.instructions, max_output_tokens=request.max_output_tokens, temperature=request.temperature, - tools=request.tools, - tool_choice=request.tool_choice, usage=usage_info, metadata=request.metadata, ) diff --git a/src/agentpool_server/openai_api_server/responses/models.py b/src/agentpool_server/openai_api_server/responses/models.py index c2474e7b1..8c657e1fe 100644 --- a/src/agentpool_server/openai_api_server/responses/models.py +++ b/src/agentpool_server/openai_api_server/responses/models.py @@ -2,95 +2,101 @@ from __future__ import annotations -from collections.abc import Sequence -from datetime import datetime -from typing import Any, Literal, TypedDict -from uuid import uuid4 +import base64 +from typing import TYPE_CHECKING, Any, Literal, assert_never +from openai.types.responses import EasyInputMessageParam from pydantic import Field +from pydantic_ai import BinaryContent, DocumentUrl, ImageUrl, UploadedFile from schemez import Schema -class InputText(Schema): - """Text input part.""" +if TYPE_CHECKING: + from openai.types.responses.response_input_content_param import ResponseInputContentParam + from pydantic_ai import UserContent - type: Literal["input_text"] = "input_text" - text: str +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] +ServiceTier = Literal["auto", "default", "flex"] +Truncation = Literal["auto", "disabled"] +GenerateSummary = Literal["auto", "concise", "detailed"] -class InputImage(Schema): - """Image input part.""" - type: Literal["input_image"] = "input_image" - image_url: str +class Reasoning(Schema): + """Reasoning/thinking configuration.""" - -class ResponseOutputText(Schema): - """Text output part.""" - - type: Literal["output_text"] = "output_text" - text: str - annotations: list[dict[str, Any]] = Field(default_factory=list) - - -class ResponseToolCall(Schema): - """Tool call in response.""" - - type: str # web_search_call etc - id: str - status: Literal["completed", "error"] = "completed" - - -class ResponseMessage(Schema): - """ResponseMessage in response.""" - - type: Literal["message"] = "message" - id: str - status: Literal["completed", "error"] = "completed" - role: Literal["user", "assistant", "system"] - content: list[ResponseOutputText] - - -class ResponseUsage(TypedDict): - """Token usage information.""" - - input_tokens: int - input_tokens_details: dict[str, int] - output_tokens: int - output_tokens_details: dict[str, int] - total_tokens: int + effort: ReasoningEffort = "medium" + generate_summary: GenerateSummary | None = None + summary: GenerateSummary | None = None class ResponseRequest(Schema): """Request for /v1/responses endpoint.""" model: str - input: str | list[dict[str, Any]] + input: str | list[EasyInputMessageParam] | None = None instructions: str | None = None + previous_response_id: str | None = None stream: bool = False temperature: float = 1.0 tools: list[dict[str, Any]] = Field(default_factory=list) tool_choice: str = "auto" max_output_tokens: int | None = None + max_tool_calls: int | None = None + parallel_tool_calls: bool = True + reasoning: Reasoning | None = None + store: bool = True + truncation: Truncation | None = None + service_tier: ServiceTier | None = None + user: str | None = None metadata: dict[str, str] = Field(default_factory=dict) -class Response(Schema): - """Response from /v1/responses endpoint.""" - - id: str = Field(default_factory=lambda: f"resp_{uuid4().hex}") - object: Literal["response"] = "response" - created_at: int = Field(default_factory=lambda: int(datetime.now().timestamp())) - status: Literal["completed", "error"] = "completed" - error: str | None = None - model: str - output: Sequence[ResponseMessage | ResponseToolCall] - - # Include all the request parameters - instructions: str | None = None - max_output_tokens: int | None = None - temperature: float = 1.0 - tools: list[dict[str, Any]] = Field(default_factory=list) - tool_choice: str = "auto" - usage: ResponseUsage | None = None - metadata: dict[str, str] = Field(default_factory=dict) +def _convert_content_part(part: ResponseInputContentParam) -> UserContent | None: + """Convert a single OpenAI input content part to pydantic-ai UserContent.""" + match part: + case {"type": "input_text", "text": str(text)}: + return text + case {"type": "input_image", "image_url": str(url), **rest}: + detail = rest.get("detail") + metadata = {"detail": detail} if detail else None + return ImageUrl(url=url, vendor_metadata=metadata) + case {"type": "input_image" | "input_file", "file_id": str(file_id)}: + return UploadedFile(file_id=file_id, provider_name="openai") + case {"type": "input_file", "file_url": str(url)}: + return DocumentUrl(url=url) + case {"type": "input_file", "file_data": str(data_str), "filename": str(filename)}: + data = base64.b64decode(data_str) + media_type = ( + "application/pdf" if filename.endswith(".pdf") else "application/octet-stream" + ) + return BinaryContent(data=data, media_type=media_type) + case _: + return None + + +def extract_user_content(request: ResponseRequest) -> list[UserContent]: + """Extract user content from a ResponseRequest as pydantic-ai UserContent parts. + + Raises: + ValueError: If input format is invalid or required fields are missing. + """ + match request.input: + case str(): + return [request.input] + case list(): + last_msg = request.input[-1] + msg_content = last_msg["content"] + if isinstance(msg_content, str): + return [msg_content] + parts = [converted for p in msg_content if (converted := _convert_content_part(p))] + if not parts: + return [""] + return parts + case None: + if request.previous_response_id is None: + msg = "Either 'input' or 'previous_response_id' is required" + raise ValueError(msg) + return [""] + case _ as unreachable: + assert_never(unreachable) diff --git a/src/agentpool_server/openai_api_server/responses/stream_adapter.py b/src/agentpool_server/openai_api_server/responses/stream_adapter.py new file mode 100644 index 000000000..fc0ee4afe --- /dev/null +++ b/src/agentpool_server/openai_api_server/responses/stream_adapter.py @@ -0,0 +1,491 @@ +"""Adapter that converts pydantic-ai stream events to OpenAI Responses API SSE events. + +Maps agent-side tool calls to MCP call output items, since MCP calls +are the Responses API's model for server-side tool execution. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +import anyenv +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseInProgressEvent, + ResponseMcpCallArgumentsDoneEvent, + ResponseMcpCallCompletedEvent, + ResponseMcpCallFailedEvent, + ResponseMcpCallInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, +) +from openai.types.responses.response_output_item import McpCall +from pydantic_ai import ( + FunctionToolCallEvent, + FunctionToolResultEvent, + PartDeltaEvent, + PartStartEvent, + RetryPromptPart, + TextPart, + TextPartDelta, + ToolCallPart, + ToolReturnPart, +) +from pydantic_ai.messages import BuiltinToolCallPart, BuiltinToolReturnPart + +from agentpool.agents.events import CompactionEvent +from agentpool.log import get_logger +from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict + + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from openai.types.responses import ( + ResponseStatus, + ) + + from agentpool.agents.events import RichAgentStreamEvent + from agentpool.common_types import SupportsRunStream + from agentpool_server.openai_api_server.responses.models import ResponseRequest + +logger = get_logger(__name__) + +SERVER_LABEL = "agentpool" + + +async def stream_responses( + agent: SupportsRunStream[Any], + request: ResponseRequest, + adapter: ResponsesStreamAdapter, +) -> AsyncGenerator[str]: + """Stream a responses API request through the adapter.""" + from fastapi import HTTPException + + from agentpool_server.openai_api_server.responses.models import extract_user_content + + try: + content_parts = extract_user_content(request) + except ValueError as e: + raise HTTPException(400, str(e)) from e + + async def _event_gen() -> AsyncGenerator[Any]: + async for event in agent.run_stream(*content_parts): + yield event + + async for line in adapter.stream(_event_gen()): + yield line + + +def _sse_line(event: Any) -> str: + """Format a Responses API event as an SSE line.""" + event_type = event.type + data = event.model_dump_json() + return f"event: {event_type}\ndata: {data}\n\n" + + +@dataclass +class ResponsesStreamAdapter: + """Converts pydantic-ai stream events to OpenAI Responses API SSE events. + + Tracks output items (message + tool calls) and emits proper lifecycle + events for each. Tool calls are mapped to MCP call items. + """ + + request: ResponseRequest + response_id: str = field(default_factory=lambda: f"resp_{uuid4().hex}") + _seq: int = field(default=0, init=False) + _msg_id: str = field(default_factory=lambda: f"msg_{uuid4().hex}") + _text_parts: list[str] = field(default_factory=list) + _output_index: int = field(default=0, init=False) + _msg_output_index: int = field(default=-1, init=False) + _msg_started: bool = field(default=False, init=False) + _tool_output_indices: dict[str, int] = field(default_factory=dict) + _tool_items: dict[str, McpCall] = field(default_factory=dict) + _created_at: int = field(default_factory=lambda: int(datetime.now().timestamp())) + + def _next_seq(self) -> int: + self._seq += 1 + return self._seq + + def _base_response(self, status: ResponseStatus = "in_progress") -> Response: + """Build the Response envelope.""" + output: list[Any] = list(self._tool_items.values()) + text = "".join(self._text_parts) + msg_status = "completed" if status == "completed" else "in_progress" + output_text = ResponseOutputText(text=text, annotations=[], type="output_text") + output_msg = ResponseOutputMessage( + id=self._msg_id, + role="assistant", + content=[output_text], + status=msg_status, + type="message", + ) + output.append(output_msg) + + return Response( + id=self.response_id, + created_at=self._created_at, + model=self.request.model, + object="response", + output=output, + parallel_tool_calls=self.request.parallel_tool_calls, + tool_choice="auto", + tools=[], + status=status, + instructions=self.request.instructions, + max_output_tokens=self.request.max_output_tokens, + temperature=self.request.temperature, + metadata=self.request.metadata, + ) + + def _ensure_msg_started(self) -> list[str]: + """Emit message output item + content part if not yet started.""" + if self._msg_started: + return [] + self._msg_started = True + self._msg_output_index = self._output_index + self._output_index += 1 + + lines: list[str] = [] + msg_item = ResponseOutputMessage( + id=self._msg_id, + role="assistant", + content=[], + status="in_progress", + type="message", + ) + lines.append( + _sse_line( + ResponseOutputItemAddedEvent( + item=msg_item, + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + type="response.output_item.added", + ) + ) + ) + text_part = ResponseOutputText(text="", annotations=[], type="output_text") + lines.append( + _sse_line( + ResponseContentPartAddedEvent( + content_index=0, + item_id=self._msg_id, + output_index=self._msg_output_index, + part=text_part, + sequence_number=self._next_seq(), + type="response.content_part.added", + ) + ) + ) + return lines + + async def stream( + self, + events: AsyncGenerator[RichAgentStreamEvent[Any]], + ) -> AsyncGenerator[str]: + """Convert agent stream events to SSE lines.""" + resp = self._base_response("in_progress") + yield _sse_line( + ResponseCreatedEvent( + response=resp, + sequence_number=self._next_seq(), + type="response.created", + ) + ) + yield _sse_line( + ResponseInProgressEvent( + response=self._base_response("in_progress"), + sequence_number=self._next_seq(), + type="response.in_progress", + ) + ) + + async for event in events: + for line in self._handle_event(event): + yield line + + for line in self._finalize(): + yield line + + def _handle_event(self, event: RichAgentStreamEvent[Any]) -> list[str]: + """Convert a single agent event to SSE lines.""" + lines: list[str] = [] + + match event: + case PartStartEvent(part=TextPart(content=delta)) if delta: + lines.extend(self._ensure_msg_started()) + self._text_parts.append(delta) + lines.append( + _sse_line( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=self._msg_id, + logprobs=[], + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + type="response.output_text.delta", + ) + ) + ) + + case PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) if delta: + lines.extend(self._ensure_msg_started()) + self._text_parts.append(delta) + lines.append( + _sse_line( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=self._msg_id, + logprobs=[], + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + type="response.output_text.delta", + ) + ) + ) + + case FunctionToolCallEvent(part=ToolCallPart() as part): + lines.extend(self._start_tool_call(part.tool_call_id, part.tool_name, part)) + + case PartStartEvent(part=BuiltinToolCallPart() as part): + lines.extend(self._start_tool_call(part.tool_call_id, part.tool_name, part)) + + case FunctionToolResultEvent( + result=ToolReturnPart(content=out, tool_name=name), + tool_call_id=tc_id, + ): + lines.extend(self._complete_tool_call(tc_id, name, str(out))) + + case PartStartEvent( + part=BuiltinToolReturnPart(content=out, tool_name=name, tool_call_id=tc_id), + ): + lines.extend(self._complete_tool_call(tc_id, name, str(out))) + + case FunctionToolResultEvent(result=RetryPromptPart() as result, tool_call_id=tc_id): + error_msg = result.model_response() + lines.extend(self._fail_tool_call(tc_id, str(error_msg))) + + case CompactionEvent(phase="starting"): + lines.extend(self._ensure_msg_started()) + text = event.format() + self._text_parts.append(text) + lines.append( + _sse_line( + ResponseTextDeltaEvent( + content_index=0, + delta=text, + item_id=self._msg_id, + logprobs=[], + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + type="response.output_text.delta", + ) + ) + ) + + return lines + + def _start_tool_call(self, tool_call_id: str, tool_name: str, part: Any) -> list[str]: + """Emit events for a new MCP tool call.""" + args = safe_args_as_dict(part, default={}) + args_json = anyenv.dump_json(args) + output_idx = self._output_index + self._output_index += 1 + self._tool_output_indices[tool_call_id] = output_idx + + item = McpCall( + id=tool_call_id, + arguments=args_json, + name=tool_name, + server_label=SERVER_LABEL, + type="mcp_call", + status="in_progress", + ) + self._tool_items[tool_call_id] = item + + return [ + _sse_line( + ResponseOutputItemAddedEvent( + item=item, + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.output_item.added", + ) + ), + _sse_line( + ResponseMcpCallInProgressEvent( + item_id=tool_call_id, + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.mcp_call.in_progress", + ) + ), + _sse_line( + ResponseMcpCallArgumentsDoneEvent( + arguments=args_json, + item_id=tool_call_id, + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.mcp_call_arguments.done", + ) + ), + ] + + def _complete_tool_call(self, tool_call_id: str, tool_name: str, output: str) -> list[str]: + """Emit events for a completed tool call.""" + output_idx = self._tool_output_indices.get(tool_call_id, 0) + lines: list[str] = [] + + if tool_call_id in self._tool_items: + item = self._tool_items[tool_call_id] + self._tool_items[tool_call_id] = McpCall( + id=item.id, + arguments=item.arguments, + name=item.name, + server_label=item.server_label, + type="mcp_call", + status="completed", + output=output, + ) + + lines.append( + _sse_line( + ResponseMcpCallCompletedEvent( + item_id=tool_call_id, + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.mcp_call.completed", + ) + ) + ) + if tool_call_id in self._tool_items: + lines.append( + _sse_line( + ResponseOutputItemDoneEvent( + item=self._tool_items[tool_call_id], + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.output_item.done", + ) + ) + ) + return lines + + def _fail_tool_call(self, tool_call_id: str, error: str) -> list[str]: + """Emit events for a failed tool call.""" + output_idx = self._tool_output_indices.get(tool_call_id, 0) + lines: list[str] = [] + + if tool_call_id in self._tool_items: + item = self._tool_items[tool_call_id] + self._tool_items[tool_call_id] = McpCall( + id=item.id, + arguments=item.arguments, + name=item.name, + server_label=item.server_label, + type="mcp_call", + status="failed", + error=error, + ) + + lines.append( + _sse_line( + ResponseMcpCallFailedEvent( + item_id=tool_call_id, + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.mcp_call.failed", + ) + ) + ) + if tool_call_id in self._tool_items: + lines.append( + _sse_line( + ResponseOutputItemDoneEvent( + item=self._tool_items[tool_call_id], + output_index=output_idx, + sequence_number=self._next_seq(), + type="response.output_item.done", + ) + ) + ) + return lines + + def _finalize(self) -> list[str]: + """Emit closing events.""" + lines: list[str] = [] + lines.extend(self._ensure_msg_started()) + + full_text = "".join(self._text_parts) + + lines.append( + _sse_line( + ResponseTextDoneEvent( + content_index=0, + item_id=self._msg_id, + logprobs=[], + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + text=full_text, + type="response.output_text.done", + ) + ) + ) + + text_part = ResponseOutputText(text=full_text, annotations=[], type="output_text") + lines.append( + _sse_line( + ResponseContentPartDoneEvent( + content_index=0, + item_id=self._msg_id, + output_index=self._msg_output_index, + part=text_part, + sequence_number=self._next_seq(), + type="response.content_part.done", + ) + ) + ) + + msg_item = ResponseOutputMessage( + id=self._msg_id, + role="assistant", + content=[text_part], + status="completed", + type="message", + ) + lines.append( + _sse_line( + ResponseOutputItemDoneEvent( + item=msg_item, + output_index=self._msg_output_index, + sequence_number=self._next_seq(), + type="response.output_item.done", + ) + ) + ) + + lines.append( + _sse_line( + ResponseCompletedEvent( + response=self._base_response("completed"), + sequence_number=self._next_seq(), + type="response.completed", + ) + ) + ) + + return lines diff --git a/src/agentpool_server/openai_api_server/server.py b/src/agentpool_server/openai_api_server/server.py index 20356899a..c0f5b9cb9 100644 --- a/src/agentpool_server/openai_api_server/server.py +++ b/src/agentpool_server/openai_api_server/server.py @@ -5,35 +5,36 @@ from typing import TYPE_CHECKING, Annotated, Any import anyenv +from openai.pagination import SyncPage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +from openai.types.completion_usage import CompletionUsage from agentpool.log import get_logger from agentpool_server import BaseServer from agentpool_server.openai_api_server.completions.helpers import stream_response -from agentpool_server.openai_api_server.completions.models import ( - ChatCompletionResponse, - Choice, - OpenAIMessage, - OpenAIModelInfo, -) +from agentpool_server.openai_api_server.completions.models import OpenAIModelInfo from agentpool_server.openai_api_server.responses.helpers import handle_request if TYPE_CHECKING: - from fastapi import Header, Response + from fastapi import Header + from fastapi.responses import Response, StreamingResponse + from openai.types.responses import Response as ResponsesResponse from agentpool import AgentPool + from agentpool.agents.base_agent import BaseAgent from agentpool_server.openai_api_server.completions.models import ChatCompletionRequest - from agentpool_server.openai_api_server.responses.models import ( - Response as ResponsesResponse, - ResponseRequest, - ) + from agentpool_server.openai_api_server.responses.models import ResponseRequest logger = get_logger(__name__) class OpenAIAPIServer(BaseServer): - """OpenAI-compatible API server backed by AgentPool. + """OpenAI-compatible API server backed by a single agent. - Provides both chat completions and responses endpoints. + Uses one main agent from the pool and exposes its available models + through the standard OpenAI models endpoint. Provides both chat + completions and responses endpoints. """ def __init__( @@ -44,19 +45,17 @@ def __init__( host: str = "0.0.0.0", port: int = 8000, cors: bool = True, - docs: bool = True, api_key: str | None = None, raise_exceptions: bool = False, ) -> None: """Initialize OpenAI-compatible server. Args: - pool: AgentPool containing available agents + pool: AgentPool with a main agent to serve name: Optional Server name (auto-generated if None) host: Host to bind server to port: Port to bind server to cors: Whether to enable CORS middleware - docs: Whether to enable API documentation endpoints api_key: Optional API key for authentication raise_exceptions: Whether to raise exceptions during server start """ @@ -68,30 +67,34 @@ def __init__( self.port = port self.api_key = api_key self.app = FastAPI() + self._last_response_id: str | None = None logfire.instrument_fastapi(self.app) if cors: from fastapi.middleware.cors import CORSMiddleware self.app.add_middleware( - CORSMiddleware, # ty: ignore[invalid-argument-type] + CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) - if not docs: - self.app.docs_url = None - self.app.redoc_url = None - # Add routes with authentication dependency dep = Depends(self.verify_api_key) self.app.get("/v1/models")(self.list_models) self.app.post("/v1/chat/completions", dependencies=[dep], response_model=None)( self.create_chat_completion ) - self.app.post("/v1/responses", dependencies=[dep])(self.create_response) + self.app.post("/v1/responses", dependencies=[dep], response_model=None)( + self.create_response + ) + + @property + def agent(self) -> BaseAgent[Any, Any]: + """The main agent serving requests.""" + return self.pool.main_agent def verify_api_key( self, authorization: Annotated[str | None, Header(alias="Authorization")] = None @@ -106,25 +109,43 @@ def verify_api_key( if self.api_key and authorization != f"Bearer {self.api_key}": raise HTTPException(401, "Invalid API key") - async def list_models(self) -> dict[str, Any]: - """List available agents as models.""" - models = [] - for name, agent in self.pool.all_agents.items(): - info = OpenAIModelInfo(id=name, created=0, description=agent.description) - models.append(info) - return {"object": "list", "data": models} + async def list_models(self) -> SyncPage[OpenAIModelInfo]: + """List available models from the agent's model discovery.""" + models: list[OpenAIModelInfo] = [] + try: + available = await self.agent.get_available_models() + if available: + models = [ + OpenAIModelInfo( + id=m.id_override or m.id, + created=0, + description=m.description or m.id, + object="model", + owned_by=m.owned_by or m.provider, + ) + for m in available + ] + except Exception: + logger.exception("Failed to get available models") + # Always include the agent's current model as fallback + if not models: + models = [ + OpenAIModelInfo( + id=self.agent.name, + created=0, + description=self.agent.description, + object="model", + owned_by="agentpool", + ) + ] + return SyncPage[OpenAIModelInfo](data=models, object="list") async def create_chat_completion(self, request: ChatCompletionRequest) -> Response: """Handle chat completion requests.""" - from fastapi import HTTPException, Response + from fastapi import Response from fastapi.responses import StreamingResponse - try: - agent = self.pool.all_agents[request.model] - except KeyError: - raise HTTPException(404, f"Model {request.model} not found") from None - - # Just take the last message content - let agent handle history + agent = self.agent content = request.messages[-1].content or "" if request.stream: return StreamingResponse( @@ -133,31 +154,70 @@ async def create_chat_completion(self, request: ChatCompletionRequest) -> Respon ) try: response = await agent.run(content) - message = OpenAIMessage(role="assistant", content=str(response.content)) - completion_response = ChatCompletionResponse( + message = ChatCompletionMessage(role="assistant", content=str(response.content)) + usage = None + if response.cost_info: + usage = CompletionUsage( + prompt_tokens=response.usage.input_tokens, + completion_tokens=response.usage.output_tokens, + total_tokens=response.usage.total_tokens, + ) + completion_response = ChatCompletion( id=response.message_id, + object="chat.completion", created=int(response.timestamp.timestamp()), model=request.model, - choices=[Choice(message=message)], - usage=response.cost_info.token_usage if response.cost_info else None, # pyright: ignore + choices=[Choice(index=0, message=message, finish_reason="stop")], + usage=usage, ) json = completion_response.model_dump_json() return Response(content=json, media_type="application/json") except Exception as e: self.log.exception("Error processing chat completion") + from fastapi import HTTPException + raise HTTPException(500, f"Error: {e!s}") from e - async def create_response(self, req_body: ResponseRequest) -> ResponsesResponse: + async def create_response( + self, + req_body: ResponseRequest, + ) -> ResponsesResponse | StreamingResponse: """Handle response creation requests.""" from fastapi import HTTPException + from fastapi.responses import StreamingResponse + + # Validate previous_response_id if provided + if ( + req_body.previous_response_id is not None + and req_body.previous_response_id != self._last_response_id + ): + raise HTTPException( + 404, + f"Response '{req_body.previous_response_id}' not found. " + "Only the most recent response ID is supported for continuation.", + ) try: - agent = self.pool.all_agents[req_body.model] - return await handle_request(req_body, agent) - except KeyError: - raise HTTPException(404, f"Model {req_body.model} not found") from None + if req_body.stream: + from agentpool_server.openai_api_server.responses.stream_adapter import ( + ResponsesStreamAdapter, + stream_responses, + ) + + adapter = ResponsesStreamAdapter(req_body) + self._last_response_id = adapter.response_id + return StreamingResponse( + stream_responses(self.agent, req_body, adapter), + media_type="text/event-stream", + ) + response = await handle_request(req_body, self.agent) + self._last_response_id = response.id + except HTTPException: + raise except Exception as e: raise HTTPException(500, str(e)) from e + else: + return response async def _start_async(self) -> None: """Start the server (blocking async - runs until stopped).""" diff --git a/src/agentpool_server/opencode_server/ENDPOINTS.md b/src/agentpool_server/opencode_server/ENDPOINTS.md index cd2b8bfd7..95e8ba516 100644 --- a/src/agentpool_server/opencode_server/ENDPOINTS.md +++ b/src/agentpool_server/opencode_server/ENDPOINTS.md @@ -1,7 +1,7 @@ # OpenCode API Compatibility Checklist This document tracks the implementation status of OpenCode-compatible API endpoints. -Last audited against OpenCode source: **2026-02-24** +Last audited against OpenCode source: **2026-03-24** ## Status Legend - [ ] Not implemented @@ -20,6 +20,7 @@ Last audited against OpenCode source: **2026-02-24** | [x] | GET | `/global/config` | Get global configuration | | [x] | PATCH | `/global/config` | Update global configuration | | [x] | POST | `/global/dispose` | Dispose all instances | +| [~] | POST | `/global/upgrade` | Upgrade opencode (stub - not applicable) | --- @@ -29,6 +30,7 @@ Last audited against OpenCode source: **2026-02-24** |--------|--------|------|-------------| | [x] | GET | `/project` | List all projects | | [x] | GET | `/project/current` | Get the current project | +| [x] | POST | `/project/git/init` | Initialize git repository | | [x] | PATCH | `/project/{projectID}` | Update project (name, icon, commands) | | [x] | GET | `/path` | Get the current path | | [x] | GET | `/vcs` | Get VCS info for current project | @@ -106,6 +108,7 @@ Last audited against OpenCode source: **2026-02-24** | [x] | GET | `/session/{id}/message` | List messages in session (supports `limit` query) | | [x] | POST | `/session/{id}/message` | Send message (wait for response) | | [x] | GET | `/session/{id}/message/{messageID}` | Get message details | +| [x] | DELETE | `/session/{id}/message/{messageID}` | Delete a message and all its parts | | [x] | DELETE | `/session/{id}/message/{messageID}/part/{partID}` | Delete a message part | | [x] | PATCH | `/session/{id}/message/{messageID}/part/{partID}` | Update a message part | | [x] | POST | `/session/{id}/prompt_async` | Send message async (no wait) | @@ -173,14 +176,17 @@ Last audited against OpenCode source: **2026-02-24** --- -## Worktrees (Experimental) +## Worktrees & Workspaces (Experimental) | Status | Method | Path | Description | |--------|--------|------|-------------| -| [-] | POST | `/experimental/worktree` | Create git worktree (not needed) | -| [-] | GET | `/experimental/worktree` | List worktrees (not needed) | -| [-] | DELETE | `/experimental/worktree` | Remove worktree (not needed) | -| [-] | POST | `/experimental/worktree/reset` | Reset worktree (not needed) | +| [x] | POST | `/experimental/worktree` | Create git worktree | +| [x] | GET | `/experimental/worktree` | List worktrees | +| [x] | DELETE | `/experimental/worktree` | Remove worktree | +| [x] | POST | `/experimental/worktree/reset` | Reset worktree | +| [x] | POST | `/experimental/workspace` | Create workspace | +| [x] | GET | `/experimental/workspace` | List workspaces | +| [x] | DELETE | `/experimental/workspace/{id}` | Remove workspace | --- @@ -189,12 +195,20 @@ Last audited against OpenCode source: **2026-02-24** | Status | Method | Path | Description | |--------|--------|------|-------------| | [x] | GET | `/lsp` | Get LSP server status | -| [x] | POST | `/lsp/start` | Start an LSP server | -| [x] | POST | `/lsp/stop` | Stop an LSP server | -| [x] | GET | `/lsp/servers` | List available LSP servers | -| [x] | GET | `/lsp/diagnostics` | Get LSP diagnostics (CLI-based) | | [x] | GET | `/formatter` | Get formatter status (stub) | +### AgentPool Extensions (commented out, not in upstream OpenCode) + +These routes were agentpool-specific extensions. OpenCode handles diagnostics +internally via tool call results, not HTTP endpoints. + +| Status | Method | Path | Description | +|--------|--------|------|-------------| +| [-] | POST | `/lsp/start` | Start an LSP server (commented out) | +| [-] | POST | `/lsp/stop` | Stop an LSP server (commented out) | +| [-] | GET | `/lsp/servers` | List available LSP servers (commented out) | +| [-] | GET | `/lsp/diagnostics` | Get LSP diagnostics (commented out) | + --- ## MCP @@ -412,3 +426,15 @@ _PARAM_NAME_MAP = { "line_hint": "lineHint", } ``` + +--- + +## Notes + +- **Diagnostics**: OpenCode does NOT expose diagnostics via HTTP routes. Diagnostics are + handled internally — LSP servers push them to in-process clients, and tools (`write`, + `edit`, `apply_patch`) include them in their return metadata after file operations. +- **LSP extensions**: The `/lsp/start`, `/lsp/stop`, `/lsp/servers`, `/lsp/diagnostics` + routes are agentpool extensions commented out in `lsp_routes.py`. +- **Upgrade**: The `/global/upgrade` route is stubbed since it's not applicable to agentpool. +- **Git init**: The `/project/git/init` route is stubbed — could be implemented later. diff --git a/src/agentpool_server/opencode_server/converters.py b/src/agentpool_server/opencode_server/converters.py index a28f3d81d..fef0d61c2 100644 --- a/src/agentpool_server/opencode_server/converters.py +++ b/src/agentpool_server/opencode_server/converters.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Any, assert_never import anyenv @@ -10,6 +11,7 @@ ModelResponse, RequestUsage, RetryPromptPart, + RunUsage, TextPart as PydanticTextPart, ToolCallPart as PydanticToolCallPart, ToolReturnPart as PydanticToolReturnPart, @@ -17,18 +19,21 @@ ) from agentpool import log +from agentpool.messaging import TokenCost from agentpool.messaging.messages import ChatMessage from agentpool.sessions.models import SessionData from agentpool.tools.manager import ToolError from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict, to_user_content_or_path_ref from agentpool.utils.time_utils import datetime_to_ms, ms_to_datetime -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( AgentPartInput, FilePartInput, MCPStatus, MessagePath, MessageTime, MessageWithParts, + ModelRef, + ResourceSource, Session, SessionRevert, SessionShare, @@ -54,16 +59,16 @@ from collections.abc import Sequence from fsspec.asyn import AsyncFileSystem - from pydantic_ai import UserContent + from pydantic_ai import FinishReason, ModelMessage, UserContent from agentpool.common_types import MCPConnectionStatus, MCPServerStatus, PathReference from agentpool.tools.manager import ToolManager - from agentpool_server.opencode_server.models import ToolState - from agentpool_server.opencode_server.models.mcp import ( + from opencode_sdk.models import ( + AnyMessageWithParts, + FinishReason as OCFinishReason, MCPConnectionStatus as OpenCodeMCPConnectionStatus, + PartInput, ) - from agentpool_server.opencode_server.models.message import PartInput - from agentpool_server.opencode_server.models.parts import ResourceSource logger = log.get_logger(__name__) @@ -80,11 +85,8 @@ def to_mcp_status(status: MCPServerStatus) -> MCPStatus: - return MCPStatus( - name=status.name, - status=to_opencode_mcp_status(status.status), - error=status.error, - ) + status_val = to_opencode_mcp_status(status.status) + return MCPStatus(name=status.name, status=status_val, error=status.error) def to_opencode_mcp_status(status: MCPConnectionStatus) -> OpenCodeMCPConnectionStatus: @@ -109,16 +111,6 @@ def _convert_params_for_ui(params: dict[str, Any]) -> dict[str, Any]: return {_PARAM_NAME_MAP.get(k, k): v for k, v in params.items()} -def _get_input_from_state(state: ToolState, *, convert_params: bool = False) -> dict[str, Any]: - """Extract input from any tool state type. - - Args: - state: Tool state to extract input from - convert_params: If True, convert param names to camelCase for UI display - """ - return _convert_params_for_ui(state.input) if convert_params else state.input - - async def _resolve_mcp_resource(source: ResourceSource, tools: ToolManager) -> str | None: """Resolve an MCP resource and return its content as text (or None if cant be read).""" try: @@ -159,8 +151,6 @@ async def extract_user_prompt_from_parts( Returns: Either a simple string (text-only) or a list of UserContent/PathReference items """ - from agentpool_server.opencode_server.models.parts import ResourceSource - result: list[UserContent | PathReference] = [] for part in parts: match part: @@ -210,7 +200,7 @@ def chat_message_to_opencode( # noqa: PLR0915 agent_name: str = "default", model_id: str = "unknown", provider_id: str = "agentpool", -) -> MessageWithParts: +) -> AnyMessageWithParts: """Convert a ChatMessage to OpenCode MessageWithParts. Args: @@ -227,15 +217,16 @@ def chat_message_to_opencode( # noqa: PLR0915 message_id = msg.message_id created_ms = datetime_to_ms(msg.timestamp) if msg.role == "user": - result = MessageWithParts.user( + user_msg = MessageWithParts.user( message_id=message_id, session_id=session_id, time=TimeCreated(created=created_ms), agent_name=agent_name, + model=ModelRef(provider_id=provider_id, model_id=model_id), ) if msg.content and isinstance(msg.content, str): ts_opt = TimeStartEndOptional(start=created_ms) - result.add_text_part(msg.content, time=ts_opt) + user_msg.add_text_part(msg.content, time=ts_opt) else: for model_msg in msg.messages: if not isinstance(model_msg, ModelRequest): @@ -250,116 +241,116 @@ def chat_message_to_opencode( # noqa: PLR0915 text = " ".join(str(c) for c in content if isinstance(c, str)) if text: ts_opt = TimeStartEndOptional(start=created_ms) - result.add_text_part(text, time=ts_opt) - else: - # Assistant message - completed_ms = created_ms - if msg.response_time: - completed_ms = created_ms + int(msg.response_time * 1000) - - tokens = Tokens.from_pydantic_ai(msg.usage) - result = MessageWithParts.assistant( - message_id=message_id, - session_id=session_id, - parent_id="", # Would need to track parent user message - model_id=msg.model_name or model_id, - provider_id=msg.provider_name or provider_id, - mode="default", - agent_name=agent_name, - path=MessagePath(cwd=working_dir, root=working_dir), - time=MessageTime(created=created_ms, completed=completed_ms), - tokens=tokens, - cost=float(msg.cost_info.total_cost) if msg.cost_info else 0.0, - finish=msg.finish_reason, - ) + user_msg.add_text_part(text, time=ts_opt) + return user_msg + # Assistant message + completed_ms = created_ms + if msg.response_time: + completed_ms = created_ms + int(msg.response_time * 1000) + + tokens = Tokens.from_pydantic_ai(msg.usage) + message = MessageWithParts.assistant( + message_id=message_id, + session_id=session_id, + parent_id="", # Would need to track parent user message + model_id=msg.model_name or model_id, + provider_id=msg.provider_name or provider_id, + mode="default", + agent_name=agent_name, + path=MessagePath(cwd=working_dir, root=working_dir), + time=MessageTime(created=created_ms, completed=completed_ms), + tokens=tokens, + cost=float(msg.cost_info.total_cost) if msg.cost_info else 0.0, + finish=to_oc_finish_reason(msg.finish_reason), + ) - result.add_step_start_part() - # Process all model messages to extract parts - tool_calls: dict[str, ToolPart] = {} - for model_msg in msg.messages: - for p in model_msg.parts: - match p: - case PydanticTextPart(content=content): - ts_opt = TimeStartEndOptional(start=created_ms, end=completed_ms) - result.add_text_part(content, time=ts_opt) - case PydanticToolCallPart(tool_name=tool_name, tool_call_id=call_id): - tool_input = _convert_params_for_ui(safe_args_as_dict(p)) - ts = TimeStart(start=created_ms) - title = f"Running {tool_name}" - running_state = ToolStateRunning(time=ts, input=tool_input, title=title) - tool_part = result.add_tool_part(tool_name, call_id, state=running_state) - tool_calls[call_id] = tool_part - case RetryPromptPart(content=retry_content, tool_name=tool_name, timestamp=ts): - retry_count = sum( - 1 - for m in msg.messages - if isinstance(m, ModelRequest) - for p in m.parts - if isinstance(p, RetryPromptPart) - ) - error_message = p.model_response() - is_retryable = True - if isinstance(retry_content, list): - error_type = "validation_error" - elif tool_name: - error_type = "tool_error" - else: - error_type = "retry" - - result.add_retry_part( - attempt=retry_count, - message=error_message, - created=int(ts.timestamp() * 1000), - is_retryable=is_retryable, - metadata={"error_type": error_type} if error_type else None, - ) - case PydanticToolReturnPart( - tool_call_id=call_id, - content=tool_content, - tool_name=tool_name, - timestamp=tool_ts, - ): - end_ms = datetime_to_ms(tool_ts) - if isinstance(tool_content, str): + message.add_step_start_part() + # Process all model messages to extract parts + tool_calls: dict[str, ToolPart] = {} + for model_msg in msg.messages: + for p in model_msg.parts: + match p: + case PydanticTextPart(content=content): + ts_opt = TimeStartEndOptional(start=created_ms, end=completed_ms) + message.add_text_part(content, time=ts_opt) + case PydanticToolCallPart(tool_name=tool_name, tool_call_id=call_id): + tool_input = _convert_params_for_ui(safe_args_as_dict(p)) + ts = TimeStart(start=created_ms) + title = f"Running {tool_name}" + running_state = ToolStateRunning(time=ts, input=tool_input, title=title) + tool_part = message.add_tool_part(tool_name, call_id, state=running_state) + tool_calls[call_id] = tool_part + case RetryPromptPart(content=retry_content, tool_name=tool_name, timestamp=ts): + retry_count = sum( + 1 + for m in msg.messages + if isinstance(m, ModelRequest) + for p in m.parts + if isinstance(p, RetryPromptPart) + ) + if isinstance(retry_content, list): + error_type = "validation_error" + elif tool_name: + error_type = "tool_error" + else: + error_type = "retry" + + message.add_retry_part( + attempt=retry_count, + message=p.model_response(), + created=int(ts.timestamp() * 1000), + is_retryable=True, + metadata={"error_type": error_type} if error_type else None, + ) + case PydanticToolReturnPart( + tool_call_id=call_id, + content=tool_content, + tool_name=tool_name, + timestamp=tool_ts, + ): + end_ms = datetime_to_ms(tool_ts) + match tool_content: + case str(): output = tool_content - elif isinstance(tool_content, dict): + case dict(): output = anyenv.dump_json(tool_content, indent=True) + case None: + output = "" + case _: + output = str(tool_content) + if existing := tool_calls.get(call_id): + if isinstance(tool_content, dict) and "error" in tool_content: + existing.state = ToolStateError( + error=str(tool_content.get("error", "Unknown error")), + input=existing.state.input, + time=TimeStartEnd(start=created_ms, end=end_ms), + ) else: - output = str(tool_content) if tool_content is not None else "" - if existing := tool_calls.get(call_id): - existing_input = _get_input_from_state(existing.state) - if isinstance(tool_content, dict) and "error" in tool_content: - existing.state = ToolStateError( - error=str(tool_content.get("error", "Unknown error")), - input=existing_input, - time=TimeStartEnd(start=created_ms, end=end_ms), - ) - else: - title = f"Completed {tool_name}" - tsc = TimeStartEndCompacted(start=created_ms, end=end_ms) - existing.state = ToolStateCompleted( - title=title, input=existing_input, output=output, time=tsc - ) + title = f"Completed {tool_name}" + tsc = TimeStartEndCompacted(start=created_ms, end=end_ms) + existing.state = ToolStateCompleted( + title=title, input=existing.state.input, output=output, time=tsc + ) + else: + # Orphan return - create completed tool part + state: ToolStateCompleted | ToolStateError + if isinstance(tool_content, dict) and "error" in tool_content: + err = str(tool_content.get("error", "Unknown error")) + ts_end = TimeStartEnd(start=created_ms, end=end_ms) + state = ToolStateError(error=err, time=ts_end) else: - # Orphan return - create completed tool part - state: ToolStateCompleted | ToolStateError - if isinstance(tool_content, dict) and "error" in tool_content: - err = str(tool_content.get("error", "Unknown error")) - ts_end = TimeStartEnd(start=created_ms, end=end_ms) - state = ToolStateError(error=err, time=ts_end) - else: - title = f"Completed {tool_name}" - tsc = TimeStartEndCompacted(start=created_ms, end=end_ms) - state = ToolStateCompleted(title=title, output=output, time=tsc) - result.add_tool_part(tool_name, call_id, state=state) - cost = float(msg.cost_info.total_cost) if msg.cost_info else 0.0 - result.add_step_finish_part(reason=msg.finish_reason or "stop", cost=cost, tokens=tokens) + title = f"Completed {tool_name}" + tsc = TimeStartEndCompacted(start=created_ms, end=end_ms) + state = ToolStateCompleted(title=title, output=output, time=tsc) + message.add_tool_part(tool_name, call_id, state=state) + cost = float(msg.cost_info.total_cost) if msg.cost_info else 0.0 + message.add_step_finish_part(reason=msg.finish_reason or "stop", cost=cost, tokens=tokens) - return result + return message def opencode_to_chat_message( - msg: MessageWithParts, + msg: AnyMessageWithParts, session_id: str | None = None, ) -> ChatMessage[str]: """Convert OpenCode MessageWithParts to ChatMessage. @@ -374,36 +365,27 @@ def opencode_to_chat_message( info = msg.info message_id = info.id session_id = info.session_id + created_ms = info.time.created + timestamp = ms_to_datetime(created_ms) + model_messages: list[ModelMessage] = [] # Determine role and extract timing if isinstance(info, UserMessage): - role = "user" - created_ms = info.time.created - model_name = info.model.model_id if info.model else None - provider_name = info.model.provider_id if info.model else None + model_name = info.model.model_id + provider_name = info.model.provider_id usage = RequestUsage() + run_usage = RunUsage() finish_reason = None - else: - role = "assistant" - created_ms = info.time.created - model_name = info.model_id - provider_name = info.provider_id - usage = RequestUsage( - input_tokens=info.tokens.input, - output_tokens=info.tokens.output, - cache_read_tokens=info.tokens.cache.read, - cache_write_tokens=info.tokens.cache.write, - ) - finish_reason = info.finish - - timestamp = ms_to_datetime(created_ms) - # Build model messages from parts - model_messages: list[ModelRequest | ModelResponse] = [] - if role == "user": - # Collect text parts into a user prompt + total_cost = None text_content = [part.text for part in msg.parts if isinstance(part, TextPart)] content = "\n".join(text_content) if text_content else "" model_messages.append(ModelRequest(parts=[UserPromptPart(content=content)])) else: + model_name = info.model_id + provider_name = info.provider_id + usage = info.tokens.to_request_usage() + run_usage = info.tokens.to_run_usage() + total_cost = info.cost + finish_reason = info.finish # Assistant message - collect response parts and tool interactions response_parts: list[Any] = [] tool_returns: list[PydanticToolReturnPart] = [] @@ -416,7 +398,7 @@ def opencode_to_chat_message( PydanticToolCallPart( tool_name=tool_name, tool_call_id=call_id, - args=_get_input_from_state(state), + args=state.input, ) ) match state: @@ -450,22 +432,57 @@ def opencode_to_chat_message( # Add tool returns as a follow-up request if any if tool_returns: model_messages.append(ModelRequest(parts=tool_returns, instructions=None)) - # Extract content for the ChatMessage - content = next((p.text for p in msg.parts if isinstance(p, TextPart)), "") + # Extract content from text parts after the last tool call, + # matching pydantic-ai's behavior of only using the final response's text. + last_tool_idx = -1 + for i, part in enumerate(msg.parts): + if isinstance(part, ToolPart): + last_tool_idx = i + content = "".join( + p.text for i, p in enumerate(msg.parts) if isinstance(p, TextPart) and i > last_tool_idx + ) return ChatMessage( content=content, - role=role, # type: ignore[arg-type] + role=info.role, message_id=message_id, session_id=session_id or session_id, timestamp=timestamp, + cost_info=TokenCost(total_cost=Decimal(total_cost)) if total_cost else None, messages=model_messages, - usage=usage, + usage=run_usage, model_name=model_name, provider_name=provider_name, - finish_reason=finish_reason, # type: ignore[arg-type] + finish_reason=to_native_finish_reason(finish_reason), ) +def to_native_finish_reason(reason: str | OCFinishReason | None) -> FinishReason: + if reason is None: + return "stop" + mapping: dict[OCFinishReason | str, FinishReason] = { + "stop": "stop", + "length": "length", + "tool-calls": "tool_call", + "content-filter": "content_filter", + "error": "error", + "unknown": "stop", + } + return mapping.get(reason, "stop") + + +def to_oc_finish_reason(reason: FinishReason | None) -> OCFinishReason | None: + if reason is None: + return None + mapping: dict[FinishReason, OCFinishReason] = { + "stop": "stop", + "length": "length", + "tool_call": "tool-calls", + "content_filter": "content-filter", + "error": "error", + } + return mapping.get(reason, "stop") + + # ============================================================================= # Session Converters # ============================================================================= @@ -481,13 +498,6 @@ def session_data_to_opencode(data: SessionData) -> Session: created_ms = datetime_to_ms(data.created_at) updated_ms = datetime_to_ms(data.last_active) # Extract revert/share from metadata if present - revert = None - share = None - if "revert" in data.metadata: - revert = SessionRevert(**data.metadata["revert"]) - if "share" in data.metadata: - share = SessionShare(**data.metadata["share"]) - return Session( id=data.session_id, project_id=data.project_id or "default", @@ -496,8 +506,8 @@ def session_data_to_opencode(data: SessionData) -> Session: version=data.version, time=TimeCreatedUpdated(created=created_ms, updated=updated_ms), parent_id=data.parent_id, - revert=revert, - share=share, + revert=SessionRevert(**data.metadata["revert"]) if "revert" in data.metadata else None, + share=SessionShare(**data.metadata["share"]) if "share" in data.metadata else None, ) diff --git a/src/agentpool_server/opencode_server/input_provider.py b/src/agentpool_server/opencode_server/input_provider.py index 3eddca9cf..5aed262a6 100644 --- a/src/agentpool_server/opencode_server/input_provider.py +++ b/src/agentpool_server/opencode_server/input_provider.py @@ -4,23 +4,28 @@ import asyncio from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +import time +from typing import TYPE_CHECKING, Any, Literal, assert_never from mcp import types from agentpool.log import get_logger from agentpool.ui.base import InputProvider -from agentpool_server.opencode_server.models import ( +from agentpool.utils.time_utils import now_ms +from opencode_sdk.models import ( PermissionAskedProperties, PermissionRequestEvent, PermissionToolInfo, + QuestionAskedEvent, + QuestionInfo, + QuestionOption, ) if TYPE_CHECKING: from agentpool.agents.context import AgentContext, ConfirmationResult - from agentpool_server.opencode_server.models import PermissionReply from agentpool_server.opencode_server.state import ServerState + from opencode_sdk.models import PermissionReply logger = get_logger(__name__) @@ -33,7 +38,97 @@ class PendingPermission: tool_name: str args: dict[str, Any] future: asyncio.Future[PermissionReply] - created_at: float = field(default_factory=lambda: __import__("time").time()) + created_at: float = field(default_factory=time.time) + + +class PermissionManager: + """Manages pending permission requests for tools that need confirmation.""" + + def __init__(self) -> None: + """Initialize PermissionManager.""" + self._pending_permissions: dict[str, PendingPermission] = {} + + def add_pending_permission( + self, + permission_id: str, + tool_name: str, + args: dict[str, Any], + future: asyncio.Future[PermissionReply], + ) -> PendingPermission: + """Add a pending permission request.""" + pending = PendingPermission( + permission_id=permission_id, + tool_name=tool_name, + args=args, + future=future, + ) + self._pending_permissions[permission_id] = pending + return pending + + def resolve_permission(self, permission_id: str, response: PermissionReply) -> bool: + """Resolve a pending permission request. + + Called by the REST endpoint when the client responds. + + Args: + permission_id: The permission request ID + response: The client's response ("once", "always", or "reject") + + Returns: + True if the permission was found and resolved, False otherwise + """ + pending = self._pending_permissions.get(permission_id) + if pending is None: + logger.warning("Permission not found", permission_id=permission_id) + return False + + if pending.future.done(): + logger.warning("Permission already resolved", permission_id=permission_id) + return False + + pending.future.set_result(response) + logger.info("Permission resolved", permission_id=permission_id, response=response) + return True + + def get_pending_permissions(self, session_id: str) -> list[PermissionAskedProperties]: + """Get all pending permission requests. + + Returns: + List of pending permission properties + """ + result: list[PermissionAskedProperties] = [] + for p in self._pending_permissions.values(): + args_preview = ", ".join(f"{k}={v!r}" for k, v in list(p.args.items())[:3]) + pattern = f"{p.tool_name}: {args_preview}" if args_preview else p.tool_name + props = PermissionAskedProperties( + id=p.permission_id, + session_id=session_id, + permission=p.tool_name, + patterns=[pattern], + metadata=p.args, + always=[pattern], + tool=PermissionToolInfo(message_id="", call_id=None), + ) + result.append(props) + return result + + def cancel_all_pending(self) -> int: + """Cancel all pending permission requests. + + Returns: + Number of permissions cancelled + """ + count = 0 + for pending in list(self._pending_permissions.values()): + if not pending.future.done(): + pending.future.cancel() + count += 1 + self._pending_permissions.clear() + logger.info("Cancelled all pending permissions", count=count) + return count + + def pop(self, permission_id: str) -> PendingPermission | None: + return self._pending_permissions.pop(permission_id, None) class OpenCodeInputProvider(InputProvider): @@ -56,14 +151,15 @@ def __init__(self, state: ServerState, session_id: str) -> None: """ self.state = state self.session_id = session_id - self._pending_permissions: dict[str, PendingPermission] = {} - self._tool_approvals: dict[str, str] = {} # tool_name -> "always" | "reject" + self.permission_manager = PermissionManager() + # tool_name -> "always" | "reject" + self._tool_approvals: dict[str, Literal["always", "reject"]] = {} self._id_counter = 0 def _generate_permission_id(self) -> str: """Generate a unique permission ID.""" self._id_counter += 1 - return f"perm_{self._id_counter}_{int(__import__('time').time() * 1000)}" + return f"perm_{self._id_counter}_{now_ms()}" async def get_tool_confirmation( self, @@ -95,17 +191,18 @@ async def get_tool_confirmation( case "reject": logger.debug("Auto-rejecting tool", tool_name=tool_name, reason="reject") return "skip" + case _ as unreachable: + assert_never(unreachable) # Create a pending permission request permission_id = self._generate_permission_id() future: asyncio.Future[PermissionReply] = asyncio.get_event_loop().create_future() - pending = PendingPermission( + self.permission_manager.add_pending_permission( permission_id=permission_id, tool_name=tool_name, args=args, future=future, ) - self._pending_permissions[permission_id] = pending max_preview_args = 3 args_preview = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:max_preview_args]) if len(args) > max_preview_args: @@ -137,7 +234,7 @@ async def get_tool_confirmation( return "skip" finally: # Clean up the pending permission - self._pending_permissions.pop(permission_id, None) + self.permission_manager.pop(permission_id) # Map OpenCode response to our confirmation result return self._handle_permission_response(response, tool_name) @@ -159,60 +256,8 @@ def _handle_permission_response( return "allow" case "reject": return "skip" - case _: - logger.warning("Unknown permission response", response=response) - return "abort_run" - - def resolve_permission(self, permission_id: str, response: PermissionReply) -> bool: - """Resolve a pending permission request. - - Called by the REST endpoint when the client responds. - - Args: - permission_id: The permission request ID - response: The client's response ("once", "always", or "reject") - - Returns: - True if the permission was found and resolved, False otherwise - """ - pending = self._pending_permissions.get(permission_id) - if pending is None: - logger.warning("Permission not found", permission_id=permission_id) - return False - - if pending.future.done(): - logger.warning("Permission already resolved", permission_id=permission_id) - return False - - pending.future.set_result(response) - logger.info( - "Permission resolved", - permission_id=permission_id, - response=response, - ) - return True - - def get_pending_permissions(self) -> list[PermissionAskedProperties]: - """Get all pending permission requests. - - Returns: - List of pending permission properties - """ - result: list[PermissionAskedProperties] = [] - for p in self._pending_permissions.values(): - args_preview = ", ".join(f"{k}={v!r}" for k, v in list(p.args.items())[:3]) - pattern = f"{p.tool_name}: {args_preview}" if args_preview else p.tool_name - props = PermissionAskedProperties( - id=p.permission_id, - session_id=self.session_id, - permission=p.tool_name, - patterns=[pattern], - metadata=p.args, - always=[pattern], - tool=PermissionToolInfo(message_id="", call_id=None), - ) - result.append(props) - return result + case _ as unreachable: + assert_never(unreachable) async def get_elicitation( self, @@ -238,10 +283,14 @@ async def get_elicitation( requestedSchema=({"enum": _} | {"type": "array", "items": {"enum": _}}) as schema ): return await self._handle_question_elicitation(params, schema) + case types.ElicitRequestFormParams(requestedSchema={"type": "string"}): + return await self._handle_text_elicitation(params) case types.ElicitRequestFormParams(requestedSchema=schema, message=msg): # For other form elicitation, we don't have UI support yet logger.info("Form elicitation request (not supported)", message=msg, schema=schema) return types.ElicitResult(action="decline") + case _ as unreachable: + assert_never(unreachable) async def _handle_question_elicitation( self, @@ -257,8 +306,6 @@ async def _handle_question_elicitation( Returns: Elicit result with user's answer """ - from agentpool_server.opencode_server.models.events import QuestionAskedEvent - from agentpool_server.opencode_server.models.question import QuestionInfo, QuestionOption from agentpool_server.opencode_server.state import PendingQuestion # Extract enum values @@ -310,10 +357,10 @@ async def _handle_question_elicitation( # Wrap the answer in a dict with a "value" key # Multi-select: return list in dict # Single-select: return string in dict - content: dict[str, str | list[str]] = ( + content: dict[str, Any] = ( {"value": answer} if is_multi else {"value": answer[0] if answer else ""} ) - return types.ElicitResult(action="accept", content=content) # pyright: ignore[reportArgumentType] # ty: ignore[invalid-argument-type] + return types.ElicitResult(action="accept", content=content) except asyncio.CancelledError: logger.info("Question cancelled", question_id=question_id) return types.ElicitResult(action="cancel") @@ -324,6 +371,54 @@ async def _handle_question_elicitation( # Clean up pending question self.state.pending_questions.pop(question_id, None) + async def _handle_text_elicitation( + self, + params: types.ElicitRequestFormParams, + ) -> types.ElicitResult | types.ErrorData: + """Handle free-form text elicitation via OpenCode question system. + + Creates a question with a single "Other" option, which triggers + the free-text input in the OpenCode UI. + + Args: + params: Form elicitation parameters with {"type": "string"} schema + + Returns: + Elicit result with user's text response + """ + from agentpool_server.opencode_server.state import PendingQuestion + + question_id = self._generate_permission_id() + opts = [QuestionOption(label="Other", description="Type your answer")] + header = params.message[:12] + question_info = QuestionInfo(question=params.message, header=header, options=opts) + future: asyncio.Future[list[list[str]]] = asyncio.get_event_loop().create_future() + self.state.pending_questions[question_id] = PendingQuestion( + session_id=self.session_id, + questions=[question_info], + future=future, + ) + event = QuestionAskedEvent.create( + request_id=question_id, + session_id=self.session_id, + questions=[question_info], + ) + await self.state.broadcast_event(event) + logger.info("Text input question asked", question_id=question_id, message=params.message) + try: + answers = await future + answer = answers[0][0] if answers and answers[0] else "" + content: dict[str, Any] = {"value": answer} + return types.ElicitResult(action="accept", content=content) + except asyncio.CancelledError: + logger.info("Question cancelled", question_id=question_id) + return types.ElicitResult(action="cancel") + except Exception as e: + logger.exception("Question failed", question_id=question_id) + return types.ErrorData(code=-1, message=f"Elicitation failed: {e}") + finally: + self.state.pending_questions.pop(question_id, None) + def clear_tool_approvals(self) -> None: """Clear all stored tool approval decisions.""" approval_count = len(self._tool_approvals) @@ -355,18 +450,3 @@ def resolve_question(self, question_id: str, answers: list[list[str]]) -> bool: future.set_result(answers) logger.info("Question resolved", question_id=question_id, answers=answers) return True - - def cancel_all_pending(self) -> int: - """Cancel all pending permission requests. - - Returns: - Number of permissions cancelled - """ - count = 0 - for pending in list(self._pending_permissions.values()): - if not pending.future.done(): - pending.future.cancel() - count += 1 - self._pending_permissions.clear() - logger.info("Cancelled all pending permissions", count=count) - return count diff --git a/src/agentpool_server/opencode_server/provider_auth.py b/src/agentpool_server/opencode_server/provider_auth.py index bdf93c6ed..50646538f 100644 --- a/src/agentpool_server/opencode_server/provider_auth.py +++ b/src/agentpool_server/opencode_server/provider_auth.py @@ -20,14 +20,11 @@ generate_pkce, ) -from agentpool_server.opencode_server.models.agent import ( - ProviderAuthAuthorization, - ProviderAuthMethod, -) +from opencode_sdk.models import OAuthAuthInfo, ProviderAuthAuthorization, ProviderAuthMethod if TYPE_CHECKING: - from agentpool_server.opencode_server.models.agent import AuthInfo + from opencode_sdk.models import AuthInfo class ProviderAuthBackend(ABC): @@ -129,13 +126,13 @@ async def callback( return True async def set_credentials(self, info: AuthInfo) -> bool: - if not info.token: + if not isinstance(info, OAuthAuthInfo): return False store = AnthropicTokenStore() token = AnthropicOAuthToken( - access_token=info.token, - refresh_token=info.refresh or "", - expires_at=info.expires or 0, + access_token=info.access, + refresh_token=info.refresh, + expires_at=info.expires, ) store.save(token) return True diff --git a/src/agentpool_server/opencode_server/routes/agent_routes.py b/src/agentpool_server/opencode_server/routes/agent_routes.py index 654c69f8f..6c69bf66e 100644 --- a/src/agentpool_server/opencode_server/routes/agent_routes.py +++ b/src/agentpool_server/opencode_server/routes/agent_routes.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, HttpUrl +from schemez.functionschema import ToolParameters from agentpool.log import get_logger from agentpool.mcp_server.manager import MCPManager @@ -17,14 +18,12 @@ ) from agentpool_server.opencode_server.converters import to_mcp_status from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( + AddMcpServerRequest, Agent, AuthInfo, Command, - ConnectionStatus, - FormatterStatus, LogRequest, - LspStatus, McpAuthorizationResponse, McpResource, MCPStatus, @@ -32,6 +31,8 @@ ProviderAuthMethod, Session, SkillInfo, + WorkspaceCreateRequest, + WorkspaceInfo, WorktreeCreateRequest, WorktreeInfo, WorktreeRemoveRequest, @@ -42,20 +43,31 @@ router = APIRouter(tags=["agent"]) -class AddMCPServerRequest(BaseModel): - """Request to add an MCP server dynamically.""" +# AddMcpServerRequest is defined in opencode_sdk.models.mcp - command: str | None = None - """Command to run (for stdio servers).""" - args: list[str] | None = None - """Arguments for the command.""" +class OAuthAuthorizeRequest(BaseModel): + """Request body for OAuth authorize.""" - url: str | None = None - """URL for HTTP/SSE servers.""" + method: int = 0 + """Auth method index into the provider's methods list.""" - env: dict[str, str] | None = None - """Environment variables for the server.""" + +class OAuthCallbackRequest(BaseModel): + """Request body for OAuth callback.""" + + method: int = 0 + """Auth method index.""" + + code: str | None = None + """OAuth authorization code.""" + + +class McpAuthCallbackRequest(BaseModel): + """Request body for MCP auth callback.""" + + code: str + """Authorization code from OAuth callback.""" def _find_mcp_manager(state: Any) -> MCPManager | None: @@ -130,7 +142,7 @@ async def get_mcp_status(state: StateDep) -> dict[str, MCPStatus]: @router.post("/mcp") -async def add_mcp_server(request: AddMCPServerRequest, state: StateDep) -> MCPStatus: +async def add_mcp_server(request: AddMcpServerRequest, state: StateDep) -> MCPStatus: """Add an MCP server dynamically. Supports stdio servers (command + args) or HTTP/SSE servers (url). @@ -152,6 +164,7 @@ async def add_mcp_server(request: AddMCPServerRequest, state: StateDep) -> MCPSt raise HTTPException(status_code=400, detail=detail) # Find the MCPManager and add the server + manager = None for provider in state.agent.tools.external_providers: match provider: case AggregatingResourceProvider(): @@ -159,7 +172,7 @@ async def add_mcp_server(request: AddMCPServerRequest, state: StateDep) -> MCPSt case MCPManager(): manager = provider case _: - manager = None + pass if manager is None: raise HTTPException(status_code=400, detail="No MCP manager available") @@ -228,10 +241,10 @@ async def start_mcp_auth(name: str, state: StateDep) -> McpAuthorizationResponse async def mcp_auth_callback( name: str, state: StateDep, - code: str | None = None, + body: McpAuthCallbackRequest | None = None, ) -> MCPStatus: """Complete OAuth authentication for an MCP server.""" - _ = state, code + _ = state, body raise HTTPException(status_code=501, detail=f"MCP OAuth not yet supported for: {name}") @@ -342,6 +355,44 @@ async def reset_worktree(request: WorktreeResetRequest, state: StateDep) -> bool return True +@router.post("/experimental/workspace") +async def create_workspace(request: WorkspaceCreateRequest, state: StateDep) -> WorkspaceInfo: + """Create a new workspace for the current project. + + Workspaces allow running agents in isolated environments. + """ + from agentpool.utils import identifiers + + workspace_id = identifiers.ascending("workspace") + workspace = WorkspaceInfo( + id=workspace_id, + type=request.type, + branch=request.branch, + name=None, + directory=state.working_dir, + extra=request.extra, + project_id="default", + ) + # Store in state + state.workspaces[workspace_id] = workspace + return workspace + + +@router.get("/experimental/workspace") +async def list_workspaces(state: StateDep) -> list[WorkspaceInfo]: + """List all workspaces.""" + return list(state.workspaces.values()) + + +@router.delete("/experimental/workspace/{workspace_id}") +async def remove_workspace(workspace_id: str, state: StateDep) -> WorkspaceInfo | None: + """Remove a workspace.""" + workspace = state.workspaces.pop(workspace_id, None) + if workspace is None: + raise HTTPException(status_code=404, detail="Workspace not found") + return workspace + + @router.get("/experimental/session") async def list_sessions_global( state: StateDep, @@ -359,13 +410,11 @@ async def list_sessions_global( """ from agentpool_server.opencode_server.converters import session_data_to_opencode - effective_limit = limit or 100 - sessions: list[Session] = [] - for data in await state.agent.list_sessions( - cwd=directory or state.agent.env.cwd, limit=effective_limit - ): - session = session_data_to_opencode(data) - sessions.append(session) + limit = limit or 100 + cwd = directory or state.agent.env.cwd + sessions = [ + session_data_to_opencode(i) for i in await state.agent.list_sessions(cwd=cwd, limit=limit) + ] # Apply filters if roots: sessions = [s for s in sessions if s.parent_id is None] @@ -387,8 +436,7 @@ async def list_tool_ids(state: StateDep) -> list[str]: OpenCode expects: Array """ try: - tools = await state.agent.tools.get_tools() - return [tool.name for tool in tools] + return [tool.name for tool in await state.agent.tools.get_tools()] except Exception: # noqa: BLE001 return [] @@ -398,7 +446,7 @@ class ToolListItem(BaseModel): id: str description: str - parameters: dict[str, Any] + parameters: ToolParameters @router.get("/experimental/tool") @@ -418,46 +466,23 @@ async def list_tools_with_schemas( # noqa: D417 - description: string - parameters: unknown (JSON schema) """ - _ = provider, model # Currently unused, for future filtering + _ = provider, model # Builtin tool filtering by provider/model happens in Agent.get_agentlet try: - result = [] - for tool in await state.agent.tools.get_tools(): - # Extract parameters schema from the OpenAI function schema - params = tool.schema["function"]["parameters"] - item = ToolListItem(id=tool.name, description=tool.description or "", parameters=params) - result.append(item) + result = [ + ToolListItem( + id=t.name, + description=t.description or "", + parameters=t.schema["function"]["parameters"], + ) + for t in await state.agent.tools.get_tools() + ] except Exception: # noqa: BLE001 return [] else: return result -@router.get("/lsp") -async def get_lsp_status(state: StateDep) -> list[LspStatus]: - """Get LSP server status. - - Returns status of all running LSP servers. - """ - servers: list[LspStatus] = [] - for server_id, server_state in state.lsp_manager._servers.items(): - status: ConnectionStatus = "connected" if server_state.initialized else "error" - servers.append( - LspStatus(id=server_id, name=server_id, status=status, root=server_state.root_uri or "") - ) - return servers - - -@router.get("/formatter") -async def get_formatter_status(state: StateDep) -> list[FormatterStatus]: - """Get formatter status. - - Returns empty list - formatters not supported yet. - """ - _ = state - return [] - - @router.get("/provider/auth") async def get_provider_auth(state: StateDep) -> dict[str, list[ProviderAuthMethod]]: """Get provider authentication methods. @@ -468,13 +493,18 @@ async def get_provider_auth(state: StateDep) -> dict[str, list[ProviderAuthMetho @router.post("/provider/{provider_id}/oauth/authorize") -async def oauth_authorize(provider_id: str, state: StateDep) -> ProviderAuthAuthorization: +async def oauth_authorize( + provider_id: str, + state: StateDep, + body: OAuthAuthorizeRequest | None = None, +) -> ProviderAuthAuthorization: """Start OAuth authorization flow for a provider. Returns URL and instructions for the user to complete authorization. """ + method = body.method if body else 0 try: - return await state.auth_service.authorize(provider_id) + return await state.auth_service.authorize(provider_id, method) except KeyError as e: raise HTTPException(status_code=404, detail=str(e)) from e @@ -483,15 +513,12 @@ async def oauth_authorize(provider_id: str, state: StateDep) -> ProviderAuthAuth async def oauth_callback( provider_id: str, state: StateDep, - code: str | None = None, - device_code: str | None = None, - verifier: str | None = None, + body: OAuthCallbackRequest | None = None, ) -> bool: """Handle OAuth callback/code exchange.""" + code = body.code if body else None try: - return await state.auth_service.callback( - provider_id, code=code, device_code=device_code, verifier=verifier - ) + return await state.auth_service.callback(provider_id, code=code) except KeyError as e: raise HTTPException(status_code=404, detail=str(e)) from e except ValueError as e: diff --git a/src/agentpool_server/opencode_server/routes/app_routes.py b/src/agentpool_server/opencode_server/routes/app_routes.py index 3ce717567..af18d5c31 100644 --- a/src/agentpool_server/opencode_server/routes/app_routes.py +++ b/src/agentpool_server/opencode_server/routes/app_routes.py @@ -11,7 +11,8 @@ from agentpool.utils.time_utils import datetime_to_ms from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from agentpool_storage.project_store import ProjectStore +from opencode_sdk.models import ( App, AppTimeInfo, PathInfo, @@ -21,7 +22,6 @@ ProjectUpdateRequest, VcsInfo, ) -from agentpool_storage.project_store import ProjectStore if TYPE_CHECKING: @@ -82,6 +82,15 @@ async def get_project_current(state: StateDep) -> Project: return _project_data_to_response(project) +@router.post("/project/git/init") +async def init_git(state: StateDep) -> Project: + """Initialize git repository for current project.""" + cwd = state.agent.env.cwd or state.working_dir + await state.agent.env.execute_command(f"git init {cwd}") + project = await _get_current_project(state) + return _project_data_to_response(project) + + @router.patch("/project/{project_id}") async def update_project(project_id: str, update: ProjectUpdateRequest, state: StateDep) -> Project: """Update project metadata (name, settings). diff --git a/src/agentpool_server/opencode_server/routes/config_routes.py b/src/agentpool_server/opencode_server/routes/config_routes.py index 0083e0b2a..3603af36c 100644 --- a/src/agentpool_server/opencode_server/routes/config_routes.py +++ b/src/agentpool_server/opencode_server/routes/config_routes.py @@ -10,7 +10,7 @@ from fastapi import APIRouter from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( Config, Mode, Model, @@ -105,7 +105,7 @@ async def _get_available_models() -> list[TokoModelInfo]: return await get_all_models(max_age=max_age) -async def _get_variants_from_agent(agent: object) -> dict[str, dict[str, object]]: +async def _get_variants_from_agent(agent: object) -> dict[str | bool, dict[str, object]]: """Get variants from agent's thought_level modes. Only supported for Codex and Claude Code agents which have static, @@ -131,12 +131,12 @@ async def _get_variants_from_agent(agent: object) -> dict[str, dict[str, object] for category in mode_categories: if category.id == "thought_level": # Convert modes to variants - the actual config is handled by set_mode - return {mode.id: {} for mode in category.available_modes} + return {mode.value: {} for mode in category.available_modes} return {} def _apply_variants_to_providers( - providers: list[Provider], variants: dict[str, dict[str, object]] + providers: list[Provider], variants: dict[str | bool, dict[str, object]] ) -> list[Provider]: """Apply variants to all models in all providers. diff --git a/src/agentpool_server/opencode_server/routes/file_routes.py b/src/agentpool_server/opencode_server/routes/file_routes.py index 199484e3c..72914b85d 100644 --- a/src/agentpool_server/opencode_server/routes/file_routes.py +++ b/src/agentpool_server/opencode_server/routes/file_routes.py @@ -12,9 +12,10 @@ import ripgrep_rs from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( FileContent, FileNode, + FileType, FindMatch, SubmatchInfo, Symbol, @@ -260,10 +261,12 @@ async def find_files( state: StateDep, query: str = Query(), dirs: str = Query(default="false"), + entry_type: FileType | None = Query(default=None, alias="type"), # noqa: B008 + limit: int | None = Query(default=None), ) -> list[str]: """Find files by name pattern (glob-style matching).""" - include_dirs = dirs.lower() == "true" - max_results = 100 + include_dirs = dirs.lower() != "false" or entry_type == "directory" + max_results = min(limit, 200) if limit is not None else 100 fs = state.fs base_path = state.base_path # Fast path: use ripgrep-rs library for local filesystems diff --git a/src/agentpool_server/opencode_server/routes/global_routes.py b/src/agentpool_server/opencode_server/routes/global_routes.py index 9d62c166a..27afaec7d 100644 --- a/src/agentpool_server/opencode_server/routes/global_routes.py +++ b/src/agentpool_server/opencode_server/routes/global_routes.py @@ -12,7 +12,7 @@ from agentpool import log from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( # noqa: TC001 +from opencode_sdk.models import ( # noqa: TC001 Config, Event, HealthResponse, @@ -114,6 +114,13 @@ async def global_dispose(state: StateDep) -> bool: return True +@router.post("/global/upgrade") +async def global_upgrade(state: StateDep) -> dict[str, object]: + """Upgrade opencode (stub - not applicable for agentpool).""" + _ = state + return {"success": False, "error": "Upgrade not supported in agentpool"} + + @router.post("/instance/dispose") async def instance_dispose(state: StateDep) -> bool: """Dispose the current instance.""" diff --git a/src/agentpool_server/opencode_server/routes/lsp_routes.py b/src/agentpool_server/opencode_server/routes/lsp_routes.py index ef1d7d86b..a4aba1e89 100644 --- a/src/agentpool_server/opencode_server/routes/lsp_routes.py +++ b/src/agentpool_server/opencode_server/routes/lsp_routes.py @@ -9,16 +9,10 @@ from contextlib import suppress import os -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( - Diagnostic, - DiagnosticRange, - FormatterStatus, - LspStatus, - LspUpdatedEvent, -) +from opencode_sdk.models import FormatterStatus, LspStatus router = APIRouter(tags=["lsp"]) @@ -50,155 +44,109 @@ async def list_lsp_servers(state: StateDep) -> list[LspStatus]: return servers -@router.post("/lsp/start") -async def start_lsp_server( - state: StateDep, - server_id: str = Query(..., description="LSP server ID (e.g., 'pyright', 'rust-analyzer')"), - root_uri: str | None = Query(None, description="Workspace root URI"), -) -> LspStatus: - """Start an LSP server. - - Starts the specified LSP server for the given workspace root. - If no root_uri is provided, uses the server's working directory. - - Args: - state: Server state dependency (injected). - server_id: The LSP server identifier (e.g., 'pyright', 'typescript'). - root_uri: Optional workspace root URI (file:// format). - - Returns: - The started server's status. - - Raises: - HTTPException: If the server fails to start or is not registered. - """ - # Default to working directory if no root provided - if root_uri is None: - root_uri = f"file://{state.working_dir}" - - try: - server_state = await state.lsp_manager.start_server(server_id, root_uri) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) from e - except RuntimeError as e: - raise HTTPException(status_code=500, detail=str(e)) from e - - # Emit lsp.updated event to notify clients of server status change - await state.broadcast_event(LspUpdatedEvent()) - # Get relative root path for response - root_path = root_uri - if root_uri.startswith("file://"): - root_path = root_uri[7:] - with suppress(ValueError): - root_path = os.path.relpath(root_path, state.working_dir) - status = "connected" if server_state.initialized else "error" - return LspStatus(id=server_id, name=server_id, root=root_path, status=status) - - -@router.post("/lsp/stop") -async def stop_lsp_server( - state: StateDep, - server_id: str = Query(..., description="LSP server ID to stop"), -) -> dict[str, str]: - """Stop an LSP server. - - Args: - state: Server state dependency (injected). - server_id: The LSP server identifier to stop. - - Returns: - Success message. - """ - await state.lsp_manager.stop_server(server_id) - # Emit lsp.updated event to notify clients of server status change - await state.broadcast_event(LspUpdatedEvent()) - return {"status": "ok", "message": f"Server {server_id} stopped"} - - -@router.get("/lsp/diagnostics") -async def get_diagnostics( - state: StateDep, - path: str | None = Query(None, description="File path to get diagnostics for"), -) -> dict[str, list[Diagnostic]]: - """Get diagnostics from all active LSP servers. - - Returns diagnostics organized by file path. If a specific path is provided, - returns diagnostics only for that file using CLI diagnostics. - - This uses CLI-based diagnostic tools (pyright, mypy, etc.) which are more - reliable for on-demand checks than the LSP push model. - - Args: - state: Server state dependency (injected). - path: Optional file path to get diagnostics for. - - Returns: - Dictionary mapping file paths to lists of diagnostic objects. - """ - results: dict[str, list[Diagnostic]] = {} - - # If a specific path is provided, run CLI diagnostics for it - if path: - # Make path absolute if needed - if not os.path.isabs(path): # noqa: PTH117 - path = os.path.join(state.working_dir, path) # noqa: PTH118 - - # Find the appropriate server for this file - server_info = state.lsp_manager.get_server_for_file(path) - if server_info and server_info.has_cli_diagnostics: - try: - result = await state.lsp_manager.run_cli_diagnostics(server_info.id, [path]) - if result.success and result.diagnostics: - for diag in result.diagnostics: - file_path = diag.file or path - if file_path not in results: - results[file_path] = [] - # Convert from 1-based (CLI tools) to 0-based (LSP) - rng = DiagnosticRange.create( - start_line=max(0, diag.line - 1), - start_char=max(0, diag.column - 1), - end_line=max(0, (diag.end_line or diag.line) - 1), - end_char=max(0, (diag.end_column or diag.column) - 1), - ) - diagnostics = Diagnostic( - range=rng, - message=diag.message, - severity=_severity_to_lsp(diag.severity), - code=diag.code, - source=diag.source or server_info.id, - ) - results[file_path].append(diagnostics) - except Exception: # noqa: BLE001 - # CLI diagnostics failed, return empty - pass - - return results - - -def _severity_to_lsp(severity: str) -> int: - """Convert severity string to LSP severity number.""" - mapping = {"error": 1, "warning": 2, "info": 3, "hint": 4} - return mapping.get(severity.lower(), 1) - - -@router.get("/lsp/servers") -async def list_available_servers(state: StateDep) -> list[dict[str, object]]: - """List all registered (available) LSP servers. - - Returns information about all LSP servers that can be started, - regardless of whether they are currently running. - - Returns: - List of server configurations. - """ - return [ - { - "id": server_id, - "extensions": config.extensions, - "running": server_id in state.lsp_manager._servers, - } - for server_id, config in state.lsp_manager._server_configs.items() - ] +# NOTE: The following routes are agentpool extensions that don't exist in upstream OpenCode. +# Commented out until we confirm they're needed. + +# @router.post("/lsp/start") +# async def start_lsp_server( +# state: StateDep, +# server_id: str = Query(..., description="LSP server ID (e.g., 'pyright', 'rust-analyzer')"), +# root_uri: str | None = Query(None, description="Workspace root URI"), +# ) -> LspStatus: +# """Start an LSP server.""" +# from fastapi import HTTPException +# from opencode_sdk.models import LspUpdatedEvent +# +# if root_uri is None: +# root_uri = f"file://{state.working_dir}" +# +# try: +# server_state = await state.lsp_manager.start_server(server_id, root_uri) +# except ValueError as e: +# raise HTTPException(status_code=404, detail=str(e)) from e +# except RuntimeError as e: +# raise HTTPException(status_code=500, detail=str(e)) from e +# +# await state.broadcast_event(LspUpdatedEvent()) +# root_path = root_uri +# if root_uri.startswith("file://"): +# root_path = root_uri[7:] +# with suppress(ValueError): +# root_path = os.path.relpath(root_path, state.working_dir) +# status = "connected" if server_state.initialized else "error" +# return LspStatus(id=server_id, name=server_id, root=root_path, status=status) + + +# @router.post("/lsp/stop") +# async def stop_lsp_server( +# state: StateDep, +# server_id: str = Query(..., description="LSP server ID to stop"), +# ) -> dict[str, str]: +# """Stop an LSP server.""" +# from opencode_sdk.models import LspUpdatedEvent +# +# await state.lsp_manager.stop_server(server_id) +# await state.broadcast_event(LspUpdatedEvent()) +# return {"status": "ok", "message": f"Server {server_id} stopped"} + + +# @router.get("/lsp/diagnostics") +# async def get_diagnostics( +# state: StateDep, +# path: str | None = Query(None, description="File path to get diagnostics for"), +# ) -> dict[str, list[Diagnostic]]: +# """Get diagnostics from all active LSP servers.""" +# from opencode_sdk.models import Diagnostic, DiagnosticRange +# from opencode_sdk.models.diagnostics import SeverityLevel +# +# def _severity_to_lsp(severity: str) -> SeverityLevel: +# mapping: dict[str, SeverityLevel] = {"error": 1, "warning": 2, "info": 3, "hint": 4} +# return mapping.get(severity.lower(), 1) +# +# results: dict[str, list[Diagnostic]] = {} +# if path: +# if not os.path.isabs(path): +# path = os.path.join(state.working_dir, path) +# server_info = state.lsp_manager.get_server_for_file(path) +# if server_info and server_info.has_cli_diagnostics: +# try: +# result = await state.lsp_manager.run_cli_diagnostics(server_info.id, [path]) +# if result.success and result.diagnostics: +# for diag in result.diagnostics: +# file_path = diag.file or path +# if file_path not in results: +# results[file_path] = [] +# rng = DiagnosticRange.create( +# start_line=max(0, diag.line - 1), +# start_char=max(0, diag.column - 1), +# end_line=max(0, (diag.end_line or diag.line) - 1), +# end_char=max(0, (diag.end_column or diag.column) - 1), +# ) +# results[file_path].append( +# Diagnostic( +# range=rng, +# message=diag.message, +# severity=_severity_to_lsp(diag.severity), +# code=diag.code, +# source=diag.source or server_info.id, +# ) +# ) +# except Exception: +# pass +# return results + + +# @router.get("/lsp/servers") +# async def list_available_servers(state: StateDep) -> list[dict[str, object]]: +# """List all registered (available) LSP servers.""" +# return [ +# { +# "id": server_id, +# "extensions": config.extensions, +# "running": server_id in state.lsp_manager._servers, +# } +# for server_id, config in state.lsp_manager._server_configs.items() +# ] # ============================================================================= @@ -219,8 +167,5 @@ async def list_formatters(state: StateDep) -> list[FormatterStatus]: Returns: List of formatter status objects. """ - # Stub implementation - formatters not yet implemented - # OpenCode has formatters like prettier, biome, etc. - # For now, return empty list _ = state # Reserved for future use return [] diff --git a/src/agentpool_server/opencode_server/routes/message_routes.py b/src/agentpool_server/opencode_server/routes/message_routes.py index 7b080bbf2..87ff2f780 100644 --- a/src/agentpool_server/opencode_server/routes/message_routes.py +++ b/src/agentpool_server/opencode_server/routes/message_routes.py @@ -3,35 +3,34 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING, Any, assert_never +from typing import Any, assert_never from fastapi import APIRouter, HTTPException, Query, status from agentpool.log import get_logger from agentpool.utils import identifiers as identifier from agentpool.utils.time_utils import now_ms -from agentpool_server.opencode_server.converters import ( - extract_user_prompt_from_parts, - opencode_to_chat_message, -) +from agentpool_server.opencode_server.converters import extract_user_prompt_from_parts from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from agentpool_server.opencode_server.stream_adapter import OpenCodeStreamAdapter +from opencode_sdk.models import ( AgentPartInput, + AnyMessageWithParts, AssistantMessage, FilePartInput, - LspUpdatedEvent, MessagePath, + MessageRemovedEvent, MessageRequest, MessageTime, MessageUpdatedEvent, MessageWithParts, + ModelRef, Part, PartRemovedEvent, PartUpdatedEvent, SessionIdleEvent, SessionStatus, SessionStatusEvent, - StepStartPart, SubtaskPartInput, TextPartInput, TimeCreated, @@ -39,86 +38,11 @@ Tokens, UserMessage, ) -from agentpool_server.opencode_server.routes.session_routes import get_or_load_session -from agentpool_server.opencode_server.stream_adapter import OpenCodeStreamAdapter - - -if TYPE_CHECKING: - from agentpool_server.opencode_server.state import ServerState logger = get_logger(__name__) - -def _warmup_lsp_for_files(state: ServerState, file_paths: list[str]) -> None: - """Warm up LSP servers for the given file paths. - - This starts LSP servers asynchronously based on file extensions. - Like OpenCode's LSP.touchFile(), this triggers server startup without waiting. - - Args: - state: Server state with LSP manager - file_paths: List of file paths that were accessed - """ - logger.info("_warmup_lsp_for_files called with", file_paths=file_paths) - lsp_manager = state.lsp_manager - - async def warmup_files() -> None: - """Start LSP servers for each file path.""" - logger.info("warmup_files task started") - - servers_started = False - for path in file_paths: - # Find appropriate server for this file - server_info = lsp_manager.get_server_for_file(path) - if server_info is None: - continue - server_id = server_info.id - if lsp_manager.is_running(server_id): - logger.info("Server with same id already running", server_id=server_id) - continue - - # Start server for workspace root - root_uri = f"file://{state.working_dir}" - logger.info("Starting server...", server_id=server_id) - try: - await lsp_manager.start_server(server_id, root_uri) - servers_started = True - logger.info("Server started successfully", server_id=server_id) - except Exception as e: # noqa: BLE001 - # Don't fail on LSP startup errors - logger.info("Failed to start server", error=e, server_id=server_id) - - # Emit lsp.updated event if any servers started - if servers_started: - logger.info("Broadcasting LspUpdatedEvent") - await state.broadcast_event(LspUpdatedEvent()) - logger.info("warmup_files task completed") - - # Run warmup in background (don't block the event handler) - logger.info("Creating background task for warmup") - state.create_background_task(warmup_files(), name="lsp-warmup") - - -async def persist_message_to_storage( - state: ServerState, - msg: MessageWithParts, - session_id: str, -) -> None: - """Persist an OpenCode message to storage. - - Converts the OpenCode MessageWithParts to ChatMessage and saves it. - - Args: - state: Server state with pool reference - msg: OpenCode message to persist - session_id: Session/conversation ID - """ - chat_msg = opencode_to_chat_message(msg, session_id=session_id) - with contextlib.suppress(Exception): - await state.storage.log_message(chat_msg) - - +DEFAULT_MODEL_REF = ModelRef(provider_id="agentpool", model_id="default") router = APIRouter(prefix="/session/{session_id}", tags=["message"]) @@ -127,9 +51,9 @@ async def list_messages( session_id: str, state: StateDep, limit: int | None = Query(default=None), -) -> list[MessageWithParts]: +) -> list[AnyMessageWithParts]: """List messages in a session.""" - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -141,13 +65,13 @@ async def _process_message( # noqa: PLR0915 session_id: str, request: MessageRequest, state: StateDep, -) -> MessageWithParts: +) -> MessageWithParts[AssistantMessage]: """Internal helper to process a message request. This does the actual work of creating messages, running the agent, and broadcasting events. Used by both sync and async endpoints. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # --- Create user message --- @@ -157,11 +81,11 @@ async def _process_message( # noqa: PLR0915 session_id=session_id, time=TimeCreated.now(), agent=request.agent or "default", - model=request.model, + model=request.model or DEFAULT_MODEL_REF, variant=request.variant, ) - user_msg_with_parts = MessageWithParts(info=user_message) + user_msg_with_parts = MessageWithParts[UserMessage](info=user_message) for part in request.parts: match part: case TextPartInput(text=text): @@ -188,7 +112,7 @@ async def _process_message( # noqa: PLR0915 assert_never(unreachable) await state.broadcast_event(PartUpdatedEvent.create(created)) state.messages[session_id].append(user_msg_with_parts) - await persist_message_to_storage(state, user_msg_with_parts, session_id) + await state.persist_message_to_storage(user_msg_with_parts, session_id) await state.broadcast_event(MessageUpdatedEvent.create(user_message)) # --- Mark session busy --- busy = SessionStatus(type="busy") @@ -203,24 +127,23 @@ async def _process_message( # noqa: PLR0915 # --- Create assistant message --- assistant_msg_id = identifier.ascending("message") now = now_ms() + model = request.model or DEFAULT_MODEL_REF assistant_msg = AssistantMessage( id=assistant_msg_id, session_id=session_id, parent_id=user_msg_id, - model_id=request.model.model_id if request.model else "default", - provider_id=request.model.provider_id if request.model else "agentpool", + model_id=model.model_id, + provider_id=model.provider_id, mode=request.agent or "default", agent=request.agent or "default", path=MessagePath(cwd=state.working_dir, root=state.working_dir), time=MessageTime(created=now), ) - assistant_msg_with_parts = MessageWithParts(info=assistant_msg, parts=[]) + assistant_msg_with_parts = MessageWithParts[AssistantMessage](info=assistant_msg, parts=[]) state.messages[session_id].append(assistant_msg_with_parts) await state.broadcast_event(MessageUpdatedEvent.create(assistant_msg)) # Step-start part - part_id = identifier.ascending("part") - step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id) - assistant_msg_with_parts.parts.append(step_start) + step_start = assistant_msg_with_parts.add_step_start_part() await state.broadcast_event(PartUpdatedEvent.create(step_start)) # --- Resolve agent and variant --- agent = state.agent @@ -232,11 +155,9 @@ async def _process_message( # noqa: PLR0915 # --- Stream via adapter --- adapter = OpenCodeStreamAdapter( - session_id=session_id, - assistant_msg_id=assistant_msg_id, assistant_msg=assistant_msg_with_parts, working_dir=state.working_dir, - on_file_paths=lambda paths: _warmup_lsp_for_files(state, paths), + on_file_paths=state._warmup_lsp_for_files, ) iterator = agent.run_stream(user_prompt, session_id=session_id) async for oc_event in adapter.process_stream(iterator): @@ -256,7 +177,7 @@ async def _process_message( # noqa: PLR0915 updated_assistant = assistant_msg.model_copy(update=update) assistant_msg_with_parts.info = updated_assistant await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant)) - await persist_message_to_storage(state, assistant_msg_with_parts, session_id) + await state.persist_message_to_storage(assistant_msg_with_parts, session_id) # --- Mark session idle --- status = SessionStatus(type="idle") state.session_status[session_id] = status @@ -275,7 +196,7 @@ async def send_message( session_id: str, request: MessageRequest, state: StateDep, -) -> MessageWithParts: +) -> MessageWithParts[AssistantMessage]: """Send a message and wait for the agent's response. This is the synchronous version - waits for completion before returning. @@ -301,9 +222,9 @@ async def send_message_async(session_id: str, request: MessageRequest, state: St @router.get("/message/{message_id}") -async def get_message(session_id: str, message_id: str, state: StateDep) -> MessageWithParts: +async def get_message(session_id: str, message_id: str, state: StateDep) -> AnyMessageWithParts: """Get a specific message.""" - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -314,6 +235,36 @@ async def get_message(session_id: str, message_id: str, state: StateDep) -> Mess raise HTTPException(status_code=404, detail="Message not found") +@router.delete("/message/{message_id}") +async def delete_message( + session_id: str, + message_id: str, + state: StateDep, +) -> bool: + """Delete a message and all its parts from a session.""" + session = await state.get_or_load_session(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + messages = state.messages.get(session_id, []) + for i, msg in enumerate(messages): + if msg.info.id == message_id: + for part in msg.parts: + await state.broadcast_event( + PartRemovedEvent.create( + session_id=session_id, + message_id=message_id, + part_id=part.id, + ) + ) + messages.pop(i) + await state.broadcast_event( + MessageRemovedEvent.create(session_id=session_id, message_id=message_id) + ) + return True + raise HTTPException(status_code=404, detail="Message not found") + + @router.delete("/message/{message_id}/part/{part_id}") async def delete_part( session_id: str, diff --git a/src/agentpool_server/opencode_server/routes/permission_routes.py b/src/agentpool_server/opencode_server/routes/permission_routes.py index 5ea2d48b1..6b8cd3246 100644 --- a/src/agentpool_server/opencode_server/routes/permission_routes.py +++ b/src/agentpool_server/opencode_server/routes/permission_routes.py @@ -6,7 +6,7 @@ from agentpool import log from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( PermissionAskedProperties, PermissionReplyRequest, PermissionResolvedEvent, @@ -21,8 +21,8 @@ async def list_permissions(state: StateDep) -> list[PermissionAskedProperties]: """List all pending permission requests across all sessions.""" result: list[PermissionAskedProperties] = [] - for input_provider in state.input_providers.values(): - result.extend(input_provider.get_pending_permissions()) + for session_id, input_provider in state.input_providers.items(): + result.extend(input_provider.permission_manager.get_pending_permissions(session_id)) return result @@ -46,10 +46,10 @@ async def reply_to_permission( # Find which session has this permission request for session_id, input_provider in state.input_providers.items(): # Check if this permission belongs to this session - if permission_id not in input_provider._pending_permissions: + if permission_id not in input_provider.permission_manager._pending_permissions: continue # Resolve the permission - resolved = input_provider.resolve_permission(permission_id, body.reply) + resolved = input_provider.permission_manager.resolve_permission(permission_id, body.reply) logger.info("Resolved permission", resolved=resolved) if not resolved: detail = "Permission not found or already resolved" diff --git a/src/agentpool_server/opencode_server/routes/pty_routes.py b/src/agentpool_server/opencode_server/routes/pty_routes.py index 4a670fda1..759090f9d 100644 --- a/src/agentpool_server/opencode_server/routes/pty_routes.py +++ b/src/agentpool_server/opencode_server/routes/pty_routes.py @@ -14,7 +14,7 @@ from agentpool import log from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( PtyCreatedEvent, PtyCreateRequest, PtyDeletedEvent, @@ -92,12 +92,10 @@ async def create_pty(request: PtyCreateRequest, state: StateDep) -> PtyInfo: title = request.title or f"Terminal {pty_id[-4:]}" # Create session tracker for WebSocket subscribers task = asyncio.create_task(_read_pty_output(manager, pty_id, state)) - session = PtySession(pty_id=pty_id, read_task=task) - _pty_sessions[pty_id] = session + _pty_sessions[pty_id] = PtySession(pty_id=pty_id, read_task=task) logger.info("PTY session registered", pty_id=pty_id, total_sessions=len(_pty_sessions)) # Start background task to read output and distribute to subscribers pty_info = PtyInfo.from_exxec(info, title=title) - # Broadcast PTY created event event = PtyCreatedEvent.create(info=pty_info) await state.broadcast_event(event) return pty_info diff --git a/src/agentpool_server/opencode_server/routes/question_routes.py b/src/agentpool_server/opencode_server/routes/question_routes.py index 773021610..02e365870 100644 --- a/src/agentpool_server/opencode_server/routes/question_routes.py +++ b/src/agentpool_server/opencode_server/routes/question_routes.py @@ -6,7 +6,7 @@ from agentpool_server.opencode_server.dependencies import StateDep from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( QuestionRejectedEvent, QuestionRepliedEvent, QuestionReply, diff --git a/src/agentpool_server/opencode_server/routes/session_routes.py b/src/agentpool_server/opencode_server/routes/session_routes.py index e15de80c8..0c050c6cf 100644 --- a/src/agentpool_server/opencode_server/routes/session_routes.py +++ b/src/agentpool_server/opencode_server/routes/session_routes.py @@ -4,33 +4,41 @@ import asyncio import contextlib -from typing import TYPE_CHECKING, Any +from typing import Any from anyenv.text_sharing.opencode import Message, MessagePart, OpenCodeSharer from fastapi import APIRouter, HTTPException -from pydantic_ai import FileUrl +from pydantic_ai import ( + FileUrl, + PartDeltaEvent, + PartStartEvent, + TextPart as PydanticTextPart, + TextPartDelta, +) from agentpool.repomap import RepoMap, find_src_files from agentpool.utils import identifiers as identifier from agentpool.utils.time_utils import now_ms from agentpool_server.opencode_server.command_validation import validate_command from agentpool_server.opencode_server.converters import ( - chat_message_to_opencode, opencode_to_session_data, session_data_to_opencode, ) from agentpool_server.opencode_server.dependencies import StateDep from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( + AnyMessageWithParts, AssistantMessage, CommandExecutedEvent, CommandRequest, FileDiff, MessagePath, + MessageRemovedEvent, MessageTime, MessageUpdatedEvent, MessageWithParts, OpenCodeBaseModel, + PartRemovedEvent, PartUpdatedEvent, PermissionAskedProperties, PermissionReplyRequest, @@ -49,8 +57,6 @@ SessionUpdatedEvent, SessionUpdateRequest, ShellRequest, - StepFinishPart, - StepStartPart, SummarizeRequest, TextPart, TimeCreatedUpdated, @@ -59,54 +65,20 @@ ) -if TYPE_CHECKING: - from agentpool_server.opencode_server.state import ServerState - - -async def get_or_load_session(state: ServerState, session_id: str) -> Session | None: - """Get session from cache or load via agent. +router = APIRouter(prefix="/session", tags=["session"]) - Returns None if session not found. - Uses agent.load_session() which handles loading from the appropriate - storage (pool storage, Claude storage, ACP server, Codex, etc.). - """ - # Check if session AND messages are already loaded - if session_id in state.sessions and session_id in state.messages: - return state.sessions[session_id] - - # Load via agent - this populates agent.conversation.chat_messages - data = await state.agent.load_session(session_id) - if data is None: - return None - - # Convert SessionData to OpenCode Session - session = session_data_to_opencode(data) - # Cache the session - state.sessions[session_id] = session - # Initialize runtime state - if session_id not in state.session_status: - state.session_status[session_id] = SessionStatus(type="idle") - # Convert agent's conversation history to OpenCode format - state.messages[session_id] = [ - chat_message_to_opencode( - chat_msg, - session_id=session_id, - working_dir=state.working_dir, - agent_name=state.agent.name, - model_id=chat_msg.model_name or "sonnet", # Normalized name from Claude storage - provider_id=chat_msg.provider_name or "claude-code", - ) - for chat_msg in state.agent.conversation.chat_messages - ] - return session +class RevertRequest(OpenCodeBaseModel): + """Request body for reverting a message.""" -router = APIRouter(prefix="/session", tags=["session"]) + message_id: str + part_id: str | None = None @router.get("") async def list_sessions( state: StateDep, + directory: str | None = None, roots: bool | None = None, start: int | None = None, search: str | None = None, @@ -118,6 +90,7 @@ async def list_sessions( from the appropriate storage (pool storage, Claude storage, ACP server, etc.). Query params: + directory: Filter sessions by project directory roots: Only return root sessions (no parentID) start: Filter sessions updated on or after this timestamp (ms since epoch) search: Filter sessions by title (case-insensitive) @@ -125,7 +98,8 @@ async def list_sessions( """ # Convert to OpenCode Session format and cache sessions: list[Session] = [] - for data in await state.agent.list_sessions(cwd=state.agent.env.cwd): + cwd = directory or state.agent.env.cwd + for data in await state.agent.list_sessions(cwd=cwd): session = session_data_to_opencode(data) # Cache in state for later use state.sessions[data.session_id] = session @@ -191,7 +165,7 @@ async def get_session(session_id: str, state: StateDep) -> Session: Loads from storage if not in memory cache. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") return session @@ -207,15 +181,24 @@ async def update_session( Supports updating title and archiving via time.archived. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") updates: dict[str, Any] = {} if request.title is not None: updates["title"] = request.title - # Always update the 'updated' timestamp - updates["time"] = TimeCreatedUpdated(created=session.time.created, updated=now_ms()) + # Handle archiving via time.archived + if request.time is not None and request.time.archived is not None: + current_time = session.time + updates["time"] = TimeCreatedUpdated( + created=current_time.created, + updated=now_ms(), + archived=request.time.archived if request.time.archived > 0 else None, + ) + else: + # Always update the 'updated' timestamp + updates["time"] = TimeCreatedUpdated(created=session.time.created, updated=now_ms()) session = session.model_copy(update=updates) state.sessions[session_id] = session # Update cache id_ = state.pool.manifest.config_file_path @@ -229,13 +212,13 @@ async def update_session( async def delete_session(session_id: str, state: StateDep) -> bool: """Delete a session from both cache and storage.""" # Check if session exists (in cache or storage) - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # Cancel any pending permissions and clean up input provider if input_provider := state.input_providers.pop(session_id, None): - input_provider.cancel_all_pending() + input_provider.permission_manager.cancel_all_pending() # Remove from cache state.sessions.pop(session_id, None) @@ -251,7 +234,7 @@ async def delete_session(session_id: str, state: StateDep) -> bool: @router.get("/{session_id}/children") async def get_session_children(session_id: str, state: StateDep) -> list[Session]: """Get all child sessions that were forked from the specified parent session.""" - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # Search all cached sessions for children @@ -261,7 +244,7 @@ async def get_session_children(session_id: str, state: StateDep) -> list[Session @router.post("/{session_id}/abort") async def abort_session(session_id: str, state: StateDep) -> bool: """Abort a running session by interrupting the agent.""" - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -302,14 +285,14 @@ async def fork_session( # noqa: D417 The newly created forked session """ # Get the original session - original_session = await get_or_load_session(state, session_id) + original_session = await state.get_or_load_session(session_id) if original_session is None: raise HTTPException(status_code=404, detail="Session not found") # Get messages from the original session original_messages = state.messages.get(session_id, []) # Filter messages if message_id is specified - messages_to_copy: list[MessageWithParts] = [] + messages_to_copy: list[AnyMessageWithParts] = [] if request and request.message_id: # Copy messages up to and including the specified message_id for msg in original_messages: @@ -351,15 +334,15 @@ async def fork_session( # noqa: D417 state.session_status[new_session_id] = SessionStatus(type="idle") state.todos[new_session_id] = [] # Copy messages to the new session (with updated session_id references) - copied_messages: list[MessageWithParts] = [] + copied_messages: list[AnyMessageWithParts] = [] for msg_with_parts in messages_to_copy: - # Create new message info with updated session_id new_info = msg_with_parts.info.model_copy(update={"session_id": new_session_id}) - # Copy parts with updated session_id new_parts = [ part.model_copy(update={"session_id": new_session_id}) for part in msg_with_parts.parts ] - copied_messages.append(MessageWithParts(info=new_info, parts=new_parts)) + copied_messages.append( + msg_with_parts.model_copy(update={"info": new_info, "parts": new_parts}) + ) state.messages[new_session_id] = copied_messages input_provider = OpenCodeInputProvider(state, new_session_id) @@ -387,7 +370,7 @@ async def init_session( # noqa: D417 Returns: True when the init task has been started (runs async) """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -475,13 +458,13 @@ async def get_session_todos(session_id: str, state: StateDep) -> list[Todo]: Returns todos from the agent pool's TodoTracker. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") # Get todos from pool's TodoTracker tracker = state.pool.todos - return [Todo(id=e.id, content=e.content, status=e.status) for e in tracker.entries] + return [Todo(content=e.content, status=e.status, priority=e.priority) for e in tracker.entries] @router.get("/{session_id}/diff") @@ -495,7 +478,7 @@ async def get_session_diff( Returns a list of file changes with unified diffs. Optionally filter to changes since a specific message. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -512,9 +495,9 @@ async def run_shell_command( session_id: str, request: ShellRequest, state: StateDep, -) -> MessageWithParts: +) -> MessageWithParts[AssistantMessage]: """Run a shell command directly.""" - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -536,7 +519,7 @@ async def run_shell_command( ) # Initialize message with empty parts - assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[]) + assistant_msg_with_parts = MessageWithParts[AssistantMessage](info=assistant_message, parts=[]) state.messages[session_id].append(assistant_msg_with_parts) # Broadcast message created await state.broadcast_event(MessageUpdatedEvent.create(assistant_message)) @@ -544,9 +527,7 @@ async def run_shell_command( state.session_status[session_id] = SessionStatus(type="busy") await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy"))) # Add step-start part - part_id = identifier.ascending("part") - step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id) - assistant_msg_with_parts.parts.append(step_start) + step_start = assistant_msg_with_parts.add_step_start_part() await state.broadcast_event(PartUpdatedEvent.create(step_start)) # Execute the command output_text = "" @@ -563,17 +544,9 @@ async def run_shell_command( response_time = now_ms() # Create text part with output - text_part = TextPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - text=f"$ {request.command}\n{output_text}", - ) - assistant_msg_with_parts.parts.append(text_part) + text_part = assistant_msg_with_parts.add_text_part(text=f"$ {request.command}\n{output_text}") await state.broadcast_event(PartUpdatedEvent.create(text_part)) - part_id = identifier.ascending("part") - step_finish = StepFinishPart(id=part_id, message_id=assistant_msg_id, session_id=session_id) - assistant_msg_with_parts.parts.append(step_finish) + step_finish = assistant_msg_with_parts.add_step_finish_part() await state.broadcast_event(PartUpdatedEvent.create(step_finish)) # Update message with completion time time_ = MessageTime(created=now, completed=response_time) @@ -594,7 +567,7 @@ async def get_pending_permissions( Returns a list of pending permissions awaiting user response. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -603,7 +576,7 @@ async def get_pending_permissions( if input_provider is None: return [] - return input_provider.get_pending_permissions() + return input_provider.permission_manager.get_pending_permissions(session_id) @router.post("/{session_id}/permissions/{permission_id}") @@ -620,7 +593,7 @@ async def respond_to_permission( - "always": Always allow this tool (remembered for session) - "reject": Reject this tool execution """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -630,7 +603,7 @@ async def respond_to_permission( raise HTTPException(status_code=404, detail="No input provider for session") # Resolve the permission - resolved = input_provider.resolve_permission(permission_id, body.reply) + resolved = input_provider.permission_manager.resolve_permission(permission_id, body.reply) if not resolved: raise HTTPException(status_code=404, detail="Permission not found or already resolved") event = PermissionResolvedEvent.create( @@ -651,24 +624,17 @@ async def summarize_session( # noqa: PLR0915 session_id: str, state: StateDep, request: SummarizeRequest | None = None, -) -> MessageWithParts: +) -> MessageWithParts[AssistantMessage]: """Summarize the session conversation. First runs the compaction pipeline to condense older messages, then streams an LLM-generated summary/continuation prompt to the user. The summary message is marked with summary=true for UI display. """ - from pydantic_ai.messages import ( - PartDeltaEvent, - PartStartEvent, - TextPart as PydanticTextPart, - TextPartDelta, - ) - from agentpool.agents.events import StreamCompleteEvent from agentpool.messaging.compaction import compact_conversation, summarizing_context - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if not state.messages.get(session_id): @@ -694,7 +660,7 @@ async def summarize_session( # noqa: PLR0915 summary=True, # Mark as summary message ) - assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[]) + assistant_msg_with_parts = MessageWithParts[AssistantMessage](info=assistant_message, parts=[]) state.messages[session_id].append(assistant_msg_with_parts) # Broadcast message created await state.broadcast_event(MessageUpdatedEvent.create(assistant_message)) @@ -702,9 +668,7 @@ async def summarize_session( # noqa: PLR0915 state.session_status[session_id] = SessionStatus(type="busy") await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy"))) # Add step-start part - part_id = identifier.ascending("part") - step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id) - assistant_msg_with_parts.parts.append(step_start) + step_start = assistant_msg_with_parts.add_step_start_part() await state.broadcast_event(PartUpdatedEvent.create(step_start)) # Step 1: Stream LLM summary generation FIRST (while we have full history) # The LLM sees the complete conversation and generates a continuation prompt. @@ -720,13 +684,7 @@ async def summarize_session( # noqa: PLR0915 # Text streaming start case PartStartEvent(part=PydanticTextPart(content=delta)): response_text = delta - text_part = TextPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - text=delta, - ) - assistant_msg_with_parts.parts.append(text_part) + text_part = assistant_msg_with_parts.add_text_part(delta) await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta)) # Text streaming delta @@ -739,11 +697,7 @@ async def summarize_session( # noqa: PLR0915 session_id=session_id, text=response_text, ) - # Update in parts list - for i, p in enumerate(assistant_msg_with_parts.parts): - if isinstance(p, TextPart) and p.id == text_part.id: - assistant_msg_with_parts.parts[i] = text_part - break + assistant_msg_with_parts.update_part(text_part) await state.broadcast_event(PartUpdatedEvent.create(text_part, delta=delta)) # Stream complete - extract token usage @@ -757,13 +711,7 @@ async def summarize_session( # noqa: PLR0915 response_time = now_ms() # Create/update text part with final response if text_part is None: - text_part = TextPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - text=response_text, - ) - assistant_msg_with_parts.parts.append(text_part) + text_part = assistant_msg_with_parts.add_text_part(response_text) await state.broadcast_event(PartUpdatedEvent.create(text_part)) # Step 2: Run compaction pipeline AFTER summary is generated @@ -794,30 +742,20 @@ async def summarize_session( # noqa: PLR0915 pass tokens = Tokens.from_pydantic_ai(usage) if usage else Tokens() # Add step-finish part - step_finish = StepFinishPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - tokens=tokens, - cost=cost, - ) - assistant_msg_with_parts.parts.append(step_finish) + step_finish = assistant_msg_with_parts.add_step_finish_part(tokens=tokens, cost=cost) await state.broadcast_event(PartUpdatedEvent.create(step_finish)) # Update message with completion time and tokens - msg_time = MessageTime(created=now, completed=response_time) - update = {"time": msg_time, "tokens": tokens, "cost": cost} - updated_assistant = assistant_message.model_copy(update=update) - assistant_msg_with_parts.info = updated_assistant - await state.broadcast_event(MessageUpdatedEvent.create(updated_assistant)) + assistant_msg_with_parts.info.time = MessageTime(created=now, completed=response_time) + assistant_msg_with_parts.info.tokens = tokens + assistant_msg_with_parts.info.cost = cost + await state.broadcast_event(MessageUpdatedEvent.create(assistant_msg_with_parts.info)) # Mark session as idle state.session_status[session_id] = SessionStatus(type="idle") await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle"))) - # Broadcast session.diff event after summarization file_ops = state.pool.file_ops diffs = [FileDiff.from_file_change(change) for change in file_ops.changes] await state.broadcast_event(SessionDiffEvent.create(session_id, diffs)) - return assistant_msg_with_parts @@ -834,7 +772,7 @@ async def share_session( Returns the updated session with the share URL. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") messages = state.messages.get(session_id, []) @@ -862,9 +800,8 @@ async def share_session( # Share via OpenCode API async with OpenCodeSharer() as sharer: result = await sharer.share_conversation(opencode_messages, title=session.title) - share_url = result.url # Store the share URL in the session - share_info = SessionShare(url=share_url) + share_info = SessionShare(url=result.url) updated_session = session.model_copy(update={"share": share_info}) state.sessions[session_id] = updated_session # Broadcast session update @@ -872,13 +809,6 @@ async def share_session( return updated_session -class RevertRequest(OpenCodeBaseModel): - """Request body for reverting a message.""" - - message_id: str - part_id: str | None = None - - @router.post("/{session_id}/revert") async def revert_session(session_id: str, request: RevertRequest, state: StateDep) -> Session: """Revert file changes and messages from a specific message. @@ -886,9 +816,7 @@ async def revert_session(session_id: str, request: RevertRequest, state: StateDe Removes messages from the revert point onwards and restores files to their state before the specified message's changes. """ - from agentpool_server.opencode_server.models import MessageRemovedEvent, PartRemovedEvent - - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -898,18 +826,13 @@ async def revert_session(session_id: str, request: RevertRequest, state: StateDe raise HTTPException(status_code=400, detail="No messages to revert") # Find the revert message index - revert_index = None - for i, msg in enumerate(messages): - if msg.info.id == request.message_id: - revert_index = i - break - - if revert_index is None: + revert_idx = next((i for i, m in enumerate(messages) if m.info.id == request.message_id), None) + if revert_idx is None: raise HTTPException(status_code=404, detail=f"Message {request.message_id} not found") # Split messages: keep messages before revert point, remove from revert point onwards - messages_to_keep = messages[:revert_index] - messages_to_remove = messages[revert_index:] + messages_to_keep = messages[:revert_idx] + messages_to_remove = messages[revert_idx:] if not messages_to_remove: raise HTTPException(status_code=400, detail="No messages to revert") @@ -922,7 +845,6 @@ async def revert_session(session_id: str, request: RevertRequest, state: StateDe for msg in messages_to_remove: # Emit message.removed event await state.broadcast_event(MessageRemovedEvent.create(session_id, msg.info.id)) - # Emit part.removed events for all parts for part in msg.parts: await state.broadcast_event(PartRemovedEvent.create(session_id, msg.info.id, part.id)) @@ -949,10 +871,8 @@ async def revert_session(session_id: str, request: RevertRequest, state: StateDe revert_info = SessionRevert(message_id=request.message_id, part_id=request.part_id) updated_session = session.model_copy(update={"revert": revert_info}) state.sessions[session_id] = updated_session - # Broadcast session update await state.broadcast_event(SessionUpdatedEvent.create(updated_session)) - # Broadcast session.diff event with current file diffs file_ops = state.pool.file_ops diffs = [FileDiff.from_file_change(change) for change in file_ops.changes] @@ -967,9 +887,7 @@ async def unrevert_session(session_id: str, state: StateDep) -> Session: Re-applies the messages and changes that were previously reverted. """ - from agentpool_server.opencode_server.models import MessageUpdatedEvent, PartUpdatedEvent - - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") @@ -1019,13 +937,13 @@ async def unrevert_session(session_id: str, state: StateDep) -> Session: @router.delete("/{session_id}/share") -async def unshare_session(session_id: str, state: StateDep) -> bool: +async def unshare_session(session_id: str, state: StateDep) -> Session: """Remove share link from a session. Note: This only removes the link from the session metadata. The shared content may still exist on the provider's servers. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.share is None: @@ -1035,7 +953,7 @@ async def unshare_session(session_id: str, state: StateDep) -> bool: state.sessions[session_id] = updated_session # Broadcast session update await state.broadcast_event(SessionUpdatedEvent.create(updated_session)) - return True + return updated_session @router.post("/{session_id}/command") @@ -1043,13 +961,13 @@ async def execute_command( # noqa: PLR0915 session_id: str, request: CommandRequest, state: StateDep, -) -> MessageWithParts: +) -> MessageWithParts[AssistantMessage]: """Execute a slash command (MCP prompt). Commands are mapped to MCP prompts. The command name is used to find the matching prompt, and arguments are parsed and passed to it. """ - session = await get_or_load_session(state, session_id) + session = await state.get_or_load_session(session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") prompts = await state.agent.tools.list_prompts() @@ -1072,9 +990,8 @@ async def execute_command( # noqa: PLR0915 now = now_ms() # Create assistant message - assistant_msg_id = identifier.ascending("message") assistant_message = AssistantMessage( - id=assistant_msg_id, + id=identifier.ascending("message"), session_id=session_id, parent_id="", model_id=request.model or "default", @@ -1084,31 +1001,25 @@ async def execute_command( # noqa: PLR0915 path=MessagePath(cwd=state.working_dir, root=state.working_dir), time=MessageTime(created=now), ) - assistant_msg_with_parts = MessageWithParts(info=assistant_message, parts=[]) + assistant_msg_with_parts = MessageWithParts[AssistantMessage](info=assistant_message, parts=[]) state.messages[session_id].append(assistant_msg_with_parts) await state.broadcast_event(MessageUpdatedEvent.create(assistant_message)) # Mark session as busy state.session_status[session_id] = SessionStatus(type="busy") await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="busy"))) - # Add step-start part - part_id = identifier.ascending("part") - step_start = StepStartPart(id=part_id, message_id=assistant_msg_id, session_id=session_id) - assistant_msg_with_parts.parts.append(step_start) + step_start = assistant_msg_with_parts.add_step_start_part() await state.broadcast_event(PartUpdatedEvent.create(step_start)) - # Get prompt content and execute through the agent try: - prompt_parts = await prompt.get_components(arguments) # Extract text content from parts prompt_texts = [] - for part in prompt_parts: - if hasattr(part, "content"): - content = part.content - if isinstance(content, str): - prompt_texts.append(content) - elif isinstance(content, list): + for part in await prompt.get_components(arguments): + match part.content: + case str(): + prompt_texts.append(part.content) + case list() as content_list: # Handle Sequence[UserContent] - for item in content: + for item in content_list: if isinstance(item, FileUrl): prompt_texts.append(item.url) elif isinstance(item, str): @@ -1123,20 +1034,9 @@ async def execute_command( # noqa: PLR0915 response_time = now_ms() # Create text part with output - text_part = TextPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - text=output_text, - ) - assistant_msg_with_parts.parts.append(text_part) + text_part = assistant_msg_with_parts.add_text_part(text=output_text) await state.broadcast_event(PartUpdatedEvent.create(text_part)) - step_finish = StepFinishPart( - id=identifier.ascending("part"), - message_id=assistant_msg_id, - session_id=session_id, - ) - assistant_msg_with_parts.parts.append(step_finish) + step_finish = assistant_msg_with_parts.add_step_finish_part() await state.broadcast_event(PartUpdatedEvent.create(step_finish)) # Update message with completion time time_ = MessageTime(created=now, completed=response_time) @@ -1148,13 +1048,11 @@ async def execute_command( # noqa: PLR0915 await state.broadcast_event(SessionStatusEvent.create(session_id, SessionStatus(type="idle"))) # Broadcast command.executed event - await state.broadcast_event( - CommandExecutedEvent.create( - name=request.command, - session_id=session_id, - arguments=request.arguments or "", - message_id=assistant_msg_id, - ) + executed_event = CommandExecutedEvent.create( + name=request.command, + session_id=session_id, + arguments=request.arguments or "", + message_id=assistant_message.id, ) - + await state.broadcast_event(executed_event) return assistant_msg_with_parts diff --git a/src/agentpool_server/opencode_server/routes/tui_routes.py b/src/agentpool_server/opencode_server/routes/tui_routes.py index b1dd26b38..23af6f31a 100644 --- a/src/agentpool_server/opencode_server/routes/tui_routes.py +++ b/src/agentpool_server/opencode_server/routes/tui_routes.py @@ -12,7 +12,7 @@ from pydantic import BaseModel, Field from agentpool_server.opencode_server.dependencies import StateDep -from agentpool_server.opencode_server.models.events import ( +from opencode_sdk.models.events import ( TuiCommandExecuteEvent, TuiCommandExecuteProperties, TuiPromptAppendEvent, diff --git a/src/agentpool_server/opencode_server/server.py b/src/agentpool_server/opencode_server/server.py index 3701997de..0b3db35b8 100644 --- a/src/agentpool_server/opencode_server/server.py +++ b/src/agentpool_server/opencode_server/server.py @@ -9,7 +9,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from fastapi import FastAPI, Request # noqa: TC002 from fastapi.exceptions import RequestValidationError @@ -17,6 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response from agentpool import log +from agentpool_server.opencode_server.converters import opencode_to_session_data from agentpool_server.opencode_server.routes import ( agent_router, app_router, @@ -32,6 +33,14 @@ tui_router, ) from agentpool_server.opencode_server.state import ServerState +from opencode_sdk.models import ( + FileWatcherUpdatedEvent, + SessionUpdatedEvent, + Todo, + TodoUpdatedEvent, + TuiToastShowEvent, + VcsBranchUpdatedEvent, +) if TYPE_CHECKING: @@ -44,7 +53,6 @@ from agentpool.utils.todos import TodoTracker -VERSION = "0.1.0" logger = log.get_logger(__name__) @@ -63,14 +71,7 @@ def render(self, content: Any) -> bytes: async def check_pypi_version(package: str = "agentpool") -> str | None: - """Check PyPI for the latest version of a package. - - Args: - package: Package name to check - - Returns: - Latest version string, or None if check fails - """ + """Check PyPI for the latest version of a package. Returns latest version string.""" import httpx try: @@ -111,21 +112,19 @@ def create_app(*, agent: BaseAgent[Any, Any], working_dir: str | None = None) -> """ import logfire + from agentpool import __version__ + if agent.agent_pool is None: - msg = "Agent must have agent_pool set" - raise ValueError(msg) + raise ValueError("Agent must have agent_pool set") state = ServerState(working_dir=working_dir or str(Path.cwd()), agent=agent) # Set up todo change callback to broadcast events async def on_todo_change(tracker: TodoTracker) -> None: """Broadcast todo updates to all active sessions.""" - from agentpool_server.opencode_server.models.events import Todo, TodoUpdatedEvent - # Convert tracker entries to OpenCode Todo models todos = [ - Todo(id=e.id, content=e.content, status=e.status, priority=e.priority) - for e in tracker.entries + Todo(content=e.content, status=e.status, priority=e.priority) for e in tracker.entries ] # Broadcast to all active sessions for session_id in state.sessions: @@ -137,9 +136,6 @@ async def on_todo_change(tracker: TodoTracker) -> None: async def on_title_generated(event: SessionMetadataGeneratedEvent) -> None: """Update session when metadata is generated by StorageManager.""" - from agentpool_server.opencode_server.converters import opencode_to_session_data - from agentpool_server.opencode_server.models.events import SessionUpdatedEvent - logger.info("on_title_generated called", session_id=event.session_id, data=event.metadata) session_id = event.session_id if session_id in state.sessions: @@ -165,19 +161,15 @@ async def on_title_generated(event: SessionMetadataGeneratedEvent) -> None: # Watchers for VCS and file events branch_watcher: Any = None - project_file_watcher: Any = None + file_watcher: Any = None @asynccontextmanager - async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: PLR0915 - nonlocal branch_watcher, project_file_watcher + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + nonlocal branch_watcher, file_watcher from watchfiles import Change from agentpool.utils.file_watcher import FileWatcher, GitBranchWatcher - from agentpool_server.opencode_server.models import ( - FileWatcherUpdatedEvent, - VcsBranchUpdatedEvent, - ) # --- Git branch watcher --- async def on_branch_change(branch: str | None) -> None: @@ -192,7 +184,7 @@ async def on_branch_change(branch: str | None) -> None: logger.info("GitBranchWatcher started", current_branch=branch_watcher.current_branch) # --- Project file watcher --- # Map watchfiles Change types to OpenCode event types - change_type_map: dict[Change, str] = { + change_type_map: dict[Change, Literal["add", "change", "unlink"]] = { Change.added: "add", Change.modified: "change", Change.deleted: "unlink", @@ -225,30 +217,23 @@ async def on_file_change(changes: AbstractSet[tuple[Change, str]]) -> None: logger.info( "Broadcasting file.watcher.updated", event_type=event_type, path=file_path ) - event = FileWatcherUpdatedEvent.create(file=file_path, event=event_type) # type: ignore[arg-type] + event = FileWatcherUpdatedEvent.create(file=file_path, event=event_type) await state.broadcast_event(event) logger.info("Setting up project FileWatcher", working_dir=state.working_dir) - project_file_watcher = FileWatcher( - paths=[state.working_dir], - callback=on_file_change, - debounce=500, # 500ms debounce to batch rapid changes - ) - await project_file_watcher.start() + file_watcher = FileWatcher(paths=[state.working_dir], callback=on_file_change, debounce=500) + await file_watcher.start() logger.info("Project FileWatcher started") # --- Version update check (triggered when first client connects) --- async def check_for_updates() -> None: """Check PyPI for updates and notify via toast.""" - from agentpool import __version__ as current_version - from agentpool_server.opencode_server.models.events import TuiToastShowEvent - latest = await check_pypi_version("agentpool") - if latest and compare_versions(current_version, latest): - logger.info("Update available", current_version=current_version, latest=latest) + if latest and compare_versions(__version__, latest): + logger.info("Update available", current_version=__version__, latest=latest) event = TuiToastShowEvent.create( title="Update Available", - message=f"agentpool {latest} is available (current: {current_version})", + message=f"agentpool {latest} is available (current: {__version__})", variant="info", duration=10000, ) @@ -262,22 +247,22 @@ async def check_for_updates() -> None: state.pool.todos.on_change = None if branch_watcher: await branch_watcher.stop() - if project_file_watcher: - await project_file_watcher.stop() + if file_watcher: + await file_watcher.stop() # Clean up LSP servers await state.lsp_manager.stop_all() app = FastAPI( title="OpenCode-Compatible API", description="AgentPool server with OpenCode API compatibility", - version=VERSION, + version=__version__, lifespan=lifespan, default_response_class=OpenCodeJSONResponse, ) # Add CORS middleware (required for OpenCode TUI) app.add_middleware( - CORSMiddleware, # ty: ignore[invalid-argument-type] + CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], diff --git a/src/agentpool_server/opencode_server/state.py b/src/agentpool_server/opencode_server/state.py index ac816d4b2..7ee39fcd8 100644 --- a/src/agentpool_server/opencode_server/state.py +++ b/src/agentpool_server/opencode_server/state.py @@ -4,14 +4,25 @@ import asyncio from collections.abc import Callable, Coroutine +import contextlib from dataclasses import dataclass, field from pathlib import Path import time from typing import TYPE_CHECKING, Any from agentpool.diagnostics.lsp_manager import LSPManager -from agentpool_server.opencode_server.models import Config +from agentpool.log import get_logger +from agentpool_server.opencode_server.converters import ( + chat_message_to_opencode, + opencode_to_chat_message, + session_data_to_opencode, +) from agentpool_server.opencode_server.provider_auth import create_default_auth_service +from opencode_sdk.models import ( + Config, + LspUpdatedEvent, + SessionStatus, +) if TYPE_CHECKING: @@ -21,19 +32,20 @@ from agentpool.delegation import AgentPool from agentpool.storage import StorageManager from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider - from agentpool_server.opencode_server.models import ( + from agentpool_server.opencode_server.provider_auth import ProviderAuthService + from opencode_sdk.models import ( + AnyMessageWithParts, Event, - MessageWithParts, QuestionInfo, + QuestionToolInfo, Session, - SessionStatus, Todo, + WorkspaceInfo, ) - from agentpool_server.opencode_server.models.question import QuestionToolInfo - from agentpool_server.opencode_server.provider_auth import ProviderAuthService # Type alias for async callback OnFirstSubscriberCallback = Callable[[], Coroutine[Any, Any, None]] +logger = get_logger(__name__) @dataclass @@ -79,10 +91,10 @@ class ServerState: session_status: dict[str, SessionStatus] = field(default_factory=dict) """Current status for each session.""" - messages: dict[str, list[MessageWithParts]] = field(default_factory=dict) + messages: dict[str, list[AnyMessageWithParts]] = field(default_factory=dict) """Runtime message cache. Also persisted via storage.""" - reverted_messages: dict[str, list[MessageWithParts]] = field(default_factory=dict) + reverted_messages: dict[str, list[AnyMessageWithParts]] = field(default_factory=dict) """Messages removed during revert, kept for unrevert.""" todos: dict[str, list[Todo]] = field(default_factory=dict) @@ -108,6 +120,9 @@ class ServerState: auth_service: ProviderAuthService = field(default_factory=create_default_auth_service) """Provider authentication service.""" + workspaces: dict[str, WorkspaceInfo] = field(default_factory=dict) + """Active workspaces.""" + def __post_init__(self) -> None: """Initialize derived state.""" self.lsp_manager = LSPManager(env=self.agent.env) @@ -165,3 +180,105 @@ async def broadcast_event(self, event: Event) -> None: print(f"Broadcasting event: {event.type} to {len(self.event_subscribers)} subscribers") for queue in self.event_subscribers: await queue.put(event) + + def _warmup_lsp_for_files(self, file_paths: list[str]) -> None: + """Warm up LSP servers for the given file paths. + + This starts LSP servers asynchronously based on file extensions. + Like OpenCode's LSP.touchFile(), this triggers server startup without waiting. + + Args: + file_paths: List of file paths that were accessed + """ + logger.info("_warmup_lsp_for_files called with", file_paths=file_paths) + lsp_manager = self.lsp_manager + + async def warmup_files() -> None: + """Start LSP servers for each file path.""" + logger.info("warmup_files task started") + + servers_started = False + for path in file_paths: + # Find appropriate server for this file + server_info = lsp_manager.get_server_for_file(path) + if server_info is None: + continue + server_id = server_info.id + if lsp_manager.is_running(server_id): + logger.info("Server with same id already running", server_id=server_id) + continue + + # Start server for workspace root + root_uri = f"file://{self.working_dir}" + logger.info("Starting server...", server_id=server_id) + try: + await lsp_manager.start_server(server_id, root_uri) + servers_started = True + logger.info("Server started successfully", server_id=server_id) + except Exception as e: # noqa: BLE001 + # Don't fail on LSP startup errors + logger.info("Failed to start server", error=e, server_id=server_id) + + # Emit lsp.updated event if any servers started + if servers_started: + logger.info("Broadcasting LspUpdatedEvent") + await self.broadcast_event(LspUpdatedEvent()) + logger.info("warmup_files task completed") + + # Run warmup in background (don't block the event handler) + logger.info("Creating background task for warmup") + self.create_background_task(warmup_files(), name="lsp-warmup") + + async def persist_message_to_storage( + self, + msg: AnyMessageWithParts, + session_id: str, + ) -> None: + """Persist an OpenCode message to storage. + + Converts the OpenCode MessageWithParts to ChatMessage and saves it. + + Args: + msg: OpenCode message to persist + session_id: Session/conversation ID + """ + chat_msg = opencode_to_chat_message(msg, session_id=session_id) + with contextlib.suppress(Exception): + await self.storage.log_message(chat_msg) + + async def get_or_load_session(self, session_id: str) -> Session | None: + """Get session from cache or load via agent. + + Returns None if session not found. + Uses agent.load_session() which handles loading from the appropriate + storage (pool storage, Claude storage, ACP server, Codex, etc.). + """ + # Check if session AND messages are already loaded + if session_id in self.sessions and session_id in self.messages: + return self.sessions[session_id] + + # Load via agent - this populates agent.conversation.chat_messages + data = await self.agent.load_session(session_id) + if data is None: + return None + + # Convert SessionData to OpenCode Session + session = session_data_to_opencode(data) + # Cache the session + self.sessions[session_id] = session + # Initialize runtime state + if session_id not in self.session_status: + self.session_status[session_id] = SessionStatus(type="idle") + # Convert agent's conversation history to OpenCode format + self.messages[session_id] = [ + chat_message_to_opencode( + chat_msg, + session_id=session_id, + working_dir=self.working_dir, + agent_name=self.agent.name, + model_id=chat_msg.model_name or "sonnet", + provider_id=chat_msg.provider_name or "claude-code", + ) + for chat_msg in self.agent.conversation.chat_messages + ] + return session diff --git a/src/agentpool_server/opencode_server/stream_adapter.py b/src/agentpool_server/opencode_server/stream_adapter.py index 6955382cf..7f1cc9499 100644 --- a/src/agentpool_server/opencode_server/stream_adapter.py +++ b/src/agentpool_server/opencode_server/stream_adapter.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, assert_never -from pydantic_ai import FunctionToolCallEvent, RequestUsage +from pydantic_ai import FunctionToolCallEvent, RunUsage from pydantic_ai.messages import ( PartDeltaEvent, PartStartEvent, @@ -41,21 +41,19 @@ from agentpool.utils.pydantic_ai_helpers import safe_args_as_dict from agentpool.utils.time_utils import now_ms from agentpool_server.opencode_server.converters import _convert_params_for_ui -from agentpool_server.opencode_server.models import ( +from opencode_sdk.models import ( + FileEditedEvent, PartUpdatedEvent, + ReasoningPart, SessionCompactedEvent, SessionErrorEvent, - Tokens, -) -from agentpool_server.opencode_server.models.events import FileEditedEvent -from agentpool_server.opencode_server.models.parts import ( - ReasoningPart, StepFinishPart, TextPart, TimeStart, TimeStartEnd, TimeStartEndCompacted, TimeStartEndOptional, + Tokens, ToolPart, ToolStateCompleted, ToolStateError, @@ -70,9 +68,12 @@ from agentpool.agents.events import ToolCallContentItem from agentpool.agents.events.events import RichAgentStreamEvent, SubAgentType from agentpool.messaging.messages import TokenCost - from agentpool_server.opencode_server.models import MessageWithParts - from agentpool_server.opencode_server.models.events import Event - from agentpool_server.opencode_server.models.parts import ToolState + from opencode_sdk.models import ( + AssistantMessage, + Event, + MessageWithParts, + ToolState, + ) logger = get_logger(__name__) @@ -85,13 +86,7 @@ class OpenCodeStreamAdapter: counters). Yields OpenCode ``Event`` objects ready for broadcasting. """ - session_id: str - """The OpenCode session ID.""" - - assistant_msg_id: str - """The assistant message ID.""" - - assistant_msg: MessageWithParts + assistant_msg: MessageWithParts[AssistantMessage] """The mutable assistant message to append parts to.""" working_dir: str @@ -102,7 +97,7 @@ class OpenCodeStreamAdapter: # --- mutable tracking state --- _response_text: str = field(default="", init=False) - _usage: RequestUsage = field(default_factory=RequestUsage, init=False) + _usage: RunUsage = field(default_factory=RunUsage, init=False) _cost_info: TokenCost | None = field(default=None, init=False) _tool_parts: dict[str, ToolPart] = field(default_factory=dict, init=False) @@ -118,12 +113,20 @@ def __post_init__(self) -> None: # --- public read-only accessors --- + @property + def session_id(self) -> str: + return self.assistant_msg.info.session_id + + @property + def assistant_msg_id(self) -> str: + return self.assistant_msg.info.id + @property def response_text(self) -> str: return self._response_text @property - def usage(self) -> RequestUsage: + def usage(self) -> RunUsage: return self._usage @property @@ -161,6 +164,7 @@ def finalize(self) -> Iterator[Event]: """ response_time = now_ms() # Final text part + time_ = TimeStartEndOptional(start=self._stream_start_ms, end=response_time) if self._response_text and self._text_part is None: # Text was never streamed incrementally — create a text part now text_part = TextPart( @@ -168,9 +172,9 @@ def finalize(self) -> Iterator[Event]: message_id=self.assistant_msg_id, session_id=self.session_id, text=self._response_text, - time=TimeStartEndOptional(start=self._stream_start_ms, end=response_time), + time=time_, ) - self.assistant_msg.parts.append(text_part) + text_part = self.assistant_msg.add_text_part(text=self._response_text, time=time_) yield PartUpdatedEvent.create(text_part) elif self._text_part is not None: # Update streamed text part with final timing @@ -179,7 +183,7 @@ def finalize(self) -> Iterator[Event]: message_id=self.assistant_msg_id, session_id=self.session_id, text=self._response_text, - time=TimeStartEndOptional(start=self._stream_start_ms, end=response_time), + time=time_, ) self.assistant_msg.update_part(final_text_part) @@ -533,23 +537,20 @@ def _on_subagent( case _: type_label = "" icon = "→" - indicator = f"{indent}{icon} {source_name}{type_label}" indicator_part = TextPart( id=identifier.ascending("part"), message_id=self.assistant_msg_id, session_id=self.session_id, - text=indicator, + text=f"{indent}{icon} {source_name}{type_label}", time=TimeStartEndOptional.now(), ) self.assistant_msg.parts.append(indicator_part) yield PartUpdatedEvent.create(indicator_part) - - content = str(msg.content) if msg.content else "(no output)" content_part = TextPart( id=identifier.ascending("part"), message_id=self.assistant_msg_id, session_id=self.session_id, - text=content, + text=str(msg.content) if msg.content else "(no output)", time=TimeStartEndOptional.now(), ) self.assistant_msg.parts.append(content_part) diff --git a/src/agentpool_storage/base.py b/src/agentpool_storage/base.py index 8c391f83f..b2ace2377 100644 --- a/src/agentpool_storage/base.py +++ b/src/agentpool_storage/base.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Self, assert_never +from pydantic_ai import RunUsage + from agentpool.utils.tasks import TaskManager @@ -15,7 +17,7 @@ from types import TracebackType from agentpool.common_types import JsonValue - from agentpool.messaging import ChatMessage, TokenCost + from agentpool.messaging import ChatMessage from agentpool.sessions.models import ProjectData, SessionData from agentpool_config.session import SessionQuery from agentpool_config.storage import BaseStorageProviderConfig @@ -276,7 +278,7 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, def aggregate_stats( self, - rows: Sequence[tuple[str | None, str | None, datetime, TokenCost | None]], + rows: Sequence[tuple[str | None, str | None, datetime, RunUsage]], group_by: GroupBy, ) -> dict[str, dict[str, Any]]: """Aggregate statistics data by specified grouping. @@ -286,7 +288,7 @@ def aggregate_stats( group_by: How to group the statistics """ stats: dict[str, dict[str, Any]] = defaultdict( - lambda: {"total_tokens": 0, "messages": 0, "models": set()} + lambda: {"usage": RunUsage(), "messages": 0, "models": set()} ) for model, agent, timestamp, token_usage in rows: @@ -304,8 +306,7 @@ def aggregate_stats( entry = stats[key] entry["messages"] += 1 - if token_usage: - entry["total_tokens"] += token_usage.token_usage.total_tokens + entry["usage"] += token_usage if model: entry["models"].add(model) diff --git a/src/agentpool_storage/claude_provider/converters.py b/src/agentpool_storage/claude_provider/converters.py index 335f53c64..f20bc4552 100644 --- a/src/agentpool_storage/claude_provider/converters.py +++ b/src/agentpool_storage/claude_provider/converters.py @@ -3,34 +3,35 @@ from __future__ import annotations from decimal import Decimal -from typing import TYPE_CHECKING -import uuid +from typing import TYPE_CHECKING, Any, cast +from clawd_code_sdk.models import ( + TextBlock as ClaudeTextBlock, + ThinkingBlock as ClaudeThinkingBlock, + ToolResultBlock as ClaudeToolResultBlock, + ToolUseBlock as ClaudeToolUseBlock, +) from clawd_code_sdk.storage.models import ( ClaudeApiMessage, ClaudeAssistantEntry, - ClaudeTextBlock, - ClaudeThinkingBlock, - ClaudeToolResultBlock, - ClaudeToolUseBlock, ClaudeUsage, ClaudeUserEntry, ClaudeUserMessage, ) -from pydantic_ai import RunUsage -from pydantic_ai.messages import ( +from pydantic_ai import ( ModelRequest, ModelResponse, + RequestUsage, + RunUsage, TextPart, ThinkingPart, ToolCallPart, ToolReturnPart, UserPromptPart, ) -from pydantic_ai.usage import RequestUsage from agentpool.messaging import ChatMessage, TokenCost -from agentpool.utils.time_utils import get_now, parse_iso_timestamp +from agentpool.utils.time_utils import parse_iso_timestamp if TYPE_CHECKING: @@ -45,44 +46,39 @@ def chat_message_to_entry( cwd: str | None = None, ) -> ClaudeUserEntry | ClaudeAssistantEntry: """Convert a ChatMessage to a Claude JSONL entry.""" - msg_uuid = message.message_id or str(uuid.uuid4()) - parent_uuid = message.parent_id - timestamp = (message.timestamp or get_now()).isoformat().replace("+00:00", "Z") + timestamp = message.timestamp.isoformat().replace("+00:00", "Z") # Build entry based on role if message.role == "user": - user_msg = ClaudeUserMessage(role="user", content=message.content) return ClaudeUserEntry( type="user", - uuid=msg_uuid, - parent_uuid=parent_uuid, + uuid=message.message_id, + parent_uuid=message.parent_id, session_id=session_id, timestamp=timestamp, - message=user_msg, + message=ClaudeUserMessage(role="user", content=message.content), cwd=cwd or "", version="agentpool", user_type="external", is_sidechain=False, ) - # Assistant message - content_blocks = [ClaudeTextBlock(type="text", text=message.content)] - usage = ClaudeUsage() - if message.cost_info: - usage = ClaudeUsage( - input_tokens=message.cost_info.token_usage.input_tokens, - output_tokens=message.cost_info.token_usage.output_tokens, - ) + usage = ClaudeUsage( + input_tokens=message.usage.input_tokens, + output_tokens=message.usage.output_tokens, + cache_read_input_tokens=message.usage.cache_read_tokens, + cache_creation_input_tokens=message.usage.cache_write_tokens, + ) assistant_msg = ClaudeApiMessage( model=message.model_name or "unknown", - id=f"msg_{msg_uuid[:20]}", + id=f"msg_{message.message_id[:20]}", role="assistant", - content=content_blocks, + content=[ClaudeTextBlock(type="text", text=message.content)], usage=usage, ) return ClaudeAssistantEntry( type="assistant", - uuid=msg_uuid, - parent_uuid=parent_uuid, + uuid=message.message_id, + parent_uuid=message.parent_id, session_id=session_id, timestamp=timestamp, message=assistant_msg, @@ -165,6 +161,8 @@ def entry_to_chat_message( cost_info = None model = None finish_reason = None + input_tokens = 0 + output_tokens = 0 if isinstance(entry, ClaudeAssistantEntry) and isinstance(message, ClaudeApiMessage): usage = message.usage input_tokens = ( @@ -172,10 +170,7 @@ def entry_to_chat_message( ) output_tokens = usage.output_tokens if input_tokens or output_tokens: - cost_info = TokenCost( - token_usage=RunUsage(input_tokens=input_tokens, output_tokens=output_tokens), - total_cost=Decimal(0), # Claude doesn't store cost directly - ) + cost_info = TokenCost(total_cost=Decimal(0)) # Claude doesn't store cost directly model = normalize_model_name(message.model) finish_reason = message.stop_reason @@ -187,6 +182,7 @@ def entry_to_chat_message( name="claude" if isinstance(entry, ClaudeAssistantEntry) else None, model_name=model, cost_info=cost_info, + usage=RunUsage(input_tokens=input_tokens, output_tokens=output_tokens), timestamp=timestamp, parent_id=entry.parent_uuid, messages=[pydantic_message] if pydantic_message else [], @@ -257,10 +253,9 @@ def build_pydantic_message( case ClaudeThinkingBlock(thinking=thinking, signature=signature) if thinking: resp_parts.append(ThinkingPart(content=thinking, signature=signature)) case ClaudeToolUseBlock(id=block_id, name=name) if block_id and name: - args = block.input or {} - resp_parts.append( - ToolCallPart(tool_name=block.name, args=args, tool_call_id=block.id) - ) + args = cast(dict[str, Any], block.input or {}) + part = ToolCallPart(tool_name=block.name, args=args, tool_call_id=block.id) + resp_parts.append(part) if not resp_parts: return None @@ -293,7 +288,7 @@ def build_pydantic_message( # conversation = get_main_conversation(entries, include_sidechains=include_sidechains) # else: # conversation = entries -# from pydantic_ai.messages import ( +# from pydantic_ai import ( # TextPart, # ThinkingPart, # ToolCallPart, diff --git a/src/agentpool_storage/claude_provider/provider.py b/src/agentpool_storage/claude_provider/provider.py index 479eafcd5..834d7b48b 100644 --- a/src/agentpool_storage/claude_provider/provider.py +++ b/src/agentpool_storage/claude_provider/provider.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any import anyenv +from clawd_code_sdk.models import ToolUseBlock as ClaudeToolUseBlock from clawd_code_sdk.storage.helpers import ( count_session_messages, encode_project_path, @@ -28,12 +29,8 @@ read_session, write_entry, ) -from clawd_code_sdk.storage.models import ( - ClaudeAssistantEntry, - ClaudeEntry, - ClaudeToolUseBlock, - ClaudeUserEntry, -) +from clawd_code_sdk.storage.models import ClaudeAssistantEntry, ClaudeEntry, ClaudeUserEntry +from pydantic_ai import RunUsage from agentpool.log import get_logger from agentpool.utils.thread_helpers import parallel_map @@ -45,7 +42,6 @@ entry_to_chat_message, normalize_model_name, ) -from agentpool_storage.models import TokenUsage if TYPE_CHECKING: @@ -98,7 +94,7 @@ class ParsedSession: tool_mapping: dict[str, str] messages: list[ChatMessage[str]] first_timestamp: datetime | None - total_tokens: int + usage: RunUsage def _read_session_metadata( @@ -147,8 +143,7 @@ def _read_session_metadata( # Get timestamp and cwd from first message entry if first_timestamp is None: first_timestamp = timestamp - if cwd is None: - cwd = data.get("cwd") + cwd = cwd or data.get("cwd") # First user message as fallback title if entry_type == "user" and title is None: @@ -205,7 +200,7 @@ def _parse_session_full(session_id: str, session_path: Path) -> ParsedSession | tool_mapping = _build_tool_id_mapping(entries) messages: list[ChatMessage[str]] = [] first_timestamp: datetime | None = None - total_tokens = 0 + usage = RunUsage() for entry in entries: msg = entry_to_chat_message(entry, session_id, tool_mapping) @@ -214,9 +209,7 @@ def _parse_session_full(session_id: str, session_path: Path) -> ParsedSession | messages.append(msg) if first_timestamp is None and msg.timestamp: first_timestamp = msg.timestamp - if msg.cost_info: - total_tokens += msg.cost_info.token_usage.total_tokens - + usage.incr(msg.usage) if not messages: return None @@ -227,7 +220,7 @@ def _parse_session_full(session_id: str, session_path: Path) -> ParsedSession | tool_mapping=tool_mapping, messages=messages, first_timestamp=first_timestamp, - total_tokens=total_tokens, + usage=usage, ) @@ -499,18 +492,13 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: if filters.query and not any(filters.query in m.content for m in parsed.messages): continue - token_usage_data: TokenUsage | None = ( - TokenUsage(total=parsed.total_tokens, prompt=0, completion=0) - if parsed.total_tokens - else None - ) conv_data = ConversationData( id=parsed.session_id, agent=parsed.messages[0].name or "claude", title=extract_title(parsed.path), start_time=(parsed.first_timestamp or get_now()).isoformat(), messages=parsed.messages, - token_usage=token_usage_data, + token_usage=parsed.usage, ) result.append(conv_data) @@ -522,7 +510,7 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, Any]]: """Get conversation statistics.""" stats: dict[str, dict[str, Any]] = defaultdict( - lambda: {"total_tokens": 0, "messages": 0, "models": set()} + lambda: {"usage": RunUsage(), "messages": 0, "models": set()} ) for _session_id, session_path in self._list_sessions(): with session_path.open("r", encoding="utf-8") as f: @@ -540,11 +528,13 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, if not isinstance(msg, dict) or msg.get("type") != "message": continue model = normalize_model_name(msg.get("model", "unknown")) - usage = msg.get("usage", {}) - input_tokens = usage.get("input_tokens", 0) or 0 - output_tokens = usage.get("output_tokens", 0) or 0 - cache_read = usage.get("cache_read_input_tokens", 0) or 0 - total_tokens = input_tokens + output_tokens + cache_read + raw_usage = msg.get("usage", {}) + run_usage = RunUsage( + input_tokens=raw_usage.get("input_tokens", 0) or 0, + output_tokens=raw_usage.get("output_tokens", 0) or 0, + cache_read_tokens=raw_usage.get("cache_read_input_tokens", 0) or 0, + cache_write_tokens=raw_usage.get("cache_creation_input_tokens", 0) or 0, + ) timestamp_str = data.get("timestamp", "") if not timestamp_str: continue @@ -563,7 +553,7 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, key = "claude" # Default agent grouping stats[key]["messages"] += 1 - stats[key]["total_tokens"] += total_tokens + stats[key]["usage"] += run_usage stats[key]["models"].add(model) # Convert sets to lists for JSON serialization diff --git a/src/agentpool_storage/codex_provider/provider.py b/src/agentpool_storage/codex_provider/provider.py index 0a6197f77..f448ec510 100644 --- a/src/agentpool_storage/codex_provider/provider.py +++ b/src/agentpool_storage/codex_provider/provider.py @@ -12,9 +12,10 @@ if TYPE_CHECKING: + from codexed.client import CodexClient + from agentpool.messaging import ChatMessage from agentpool_storage.models import QueryFilters - from codex_adapter.client import CodexClient logger = get_logger(__name__) @@ -120,12 +121,12 @@ async def get_session_messages( return [] try: - response = await self._client.thread_resume(session_id) + session = await self._client.thread_resume(session_id) except Exception: logger.exception("Failed to load Codex thread", session_id=session_id) return [] - if not response.thread.turns: + if not session.response.thread.turns: return [] - return turns_to_chat_messages(response.thread.turns) + return turns_to_chat_messages(session.response.thread.turns) diff --git a/src/agentpool_storage/file_provider/provider.py b/src/agentpool_storage/file_provider/provider.py index 50775f18d..59613290f 100644 --- a/src/agentpool_storage/file_provider/provider.py +++ b/src/agentpool_storage/file_provider/provider.py @@ -6,6 +6,7 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any, TypedDict, cast +from pydantic import TypeAdapter from pydantic_ai import FinishReason, RunUsage # noqa: TC002 from upathtools import to_upath @@ -16,7 +17,6 @@ from agentpool.utils.time_utils import get_now from agentpool_config.storage import FileStorageConfig from agentpool_storage.base import StorageProvider -from agentpool_storage.models import TokenUsage if TYPE_CHECKING: @@ -39,7 +39,7 @@ class MessageData(TypedDict): name: str | None model: str | None cost: Decimal | None - token_usage: TokenUsage | None + token_usage: RunUsage | None response_time: float | None provider_name: str | None provider_response_id: str | None @@ -158,25 +158,18 @@ async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]: continue if query.roles and msg["role"] not in query.roles: continue - # Convert to ChatMessage - cost_info = None - if msg["token_usage"]: - usage = msg["token_usage"] - cost = Decimal(msg["cost"] or 0.0) - run_usage = RunUsage( - input_tokens=usage["prompt"], - output_tokens=usage["completion"], - ) - cost_info = TokenCost(token_usage=run_usage, total_cost=cost) - + cost = Decimal(msg["cost"] or 0.0) chat_message = ChatMessage[str]( content=msg["content"], session_id=msg["session_id"], role=cast(MessageRole, msg["role"]), name=msg["name"], model_name=msg["model"], - cost_info=cost_info, + cost_info=TokenCost(total_cost=cost), + usage=TypeAdapter(RunUsage).validate_python(msg["token_usage"]) + if msg["token_usage"] + else RunUsage(), response_time=msg["response_time"], timestamp=datetime.fromisoformat(msg["timestamp"]), provider_name=msg["provider_name"], @@ -195,7 +188,6 @@ async def log_message(self, *, message: ChatMessage[Any]) -> None: """Log a new message.""" from agentpool.storage.serialization import serialize_messages - cost_info = message.cost_info self._data["messages"].append({ "session_id": message.session_id or "", "message_id": message.message_id, @@ -204,12 +196,8 @@ async def log_message(self, *, message: ChatMessage[Any]) -> None: "timestamp": get_now().isoformat(), "name": message.name, "model": message.model_name, - "cost": Decimal(cost_info.total_cost) if cost_info else None, - "token_usage": TokenUsage( - prompt=cost_info.token_usage.input_tokens if cost_info else 0, - completion=cost_info.token_usage.output_tokens if cost_info else 0, - total=cost_info.token_usage.total_tokens if cost_info else 0, - ), + "cost": Decimal(info.total_cost) if (info := message.cost_info) else None, + "token_usage": message.usage, "response_time": message.response_time, "provider_name": message.provider_name, "provider_response_id": message.provider_response_id, @@ -259,7 +247,7 @@ async def get_session_messages( ) -> list[ChatMessage[str]]: """Get all messages for a session.""" messages = [ - self._to_chat_message(msg) + _to_chat_message(msg) for msg in self._data["messages"] if msg["session_id"] == session_id ] @@ -275,38 +263,6 @@ async def get_session_messages( return ancestors + messages return messages - def _to_chat_message(self, msg: MessageData) -> ChatMessage[str]: - """Convert stored message data to ChatMessage.""" - cost_info = None - if msg.get("token_usage"): - usage = msg["token_usage"] - cost_info = TokenCost( - token_usage=RunUsage( - input_tokens=usage.get("prompt", 0) if usage else 0, - output_tokens=usage.get("completion", 0) if usage else 0, - ), - total_cost=Decimal(str(msg.get("cost") or 0)), - ) - - # Build kwargs, only including timestamp/message_id if they have values - kwargs: dict[str, Any] = { - "content": msg["content"], - "role": cast(MessageRole, msg["role"]), - "name": msg.get("name"), - "model_name": msg.get("model"), - "cost_info": cost_info, - "response_time": msg.get("response_time"), - "parent_id": msg.get("parent_id"), - "session_id": msg.get("session_id"), - "messages": deserialize_messages(msg.get("messages")), - "finish_reason": msg.get("finish_reason"), - } - if msg.get("timestamp"): - kwargs["timestamp"] = datetime.fromisoformat(msg["timestamp"]) - if msg.get("message_id"): - kwargs["message_id"] = msg["message_id"] - return ChatMessage[str](**kwargs) - async def get_message( self, message_id: str, @@ -316,7 +272,7 @@ async def get_message( """Get a single message by ID.""" return next( ( - self._to_chat_message(m) + _to_chat_message(m) for m in self._data["messages"] if m.get("message_id") == message_id ), @@ -358,8 +314,7 @@ async def fork_conversation( None, ) if not source_conv: - msg = f"Source conversation not found: {source_session_id}" - raise ValueError(msg) + raise ValueError(f"Source conversation not found: {source_session_id}") # Determine fork point fork_point_id: str | None = None @@ -451,8 +406,7 @@ async def reset(self, *, agent_name: str | None = None, hard: bool = False) -> t if hard: if agent_name: - msg = "Hard reset cannot be used with agent_name" - raise ValueError(msg) + raise ValueError("Hard reset cannot be used with agent_name") # Clear everything self._data = { "messages": [], @@ -606,3 +560,29 @@ async def touch_project(self, project_id: str) -> None: p["last_active"] = get_now().isoformat() self._save() return + + +def _to_chat_message(msg: MessageData) -> ChatMessage[str]: + """Convert stored message data to ChatMessage.""" + cost_info = TokenCost(total_cost=Decimal(str(msg.get("cost") or 0))) + # Build kwargs, only including timestamp/message_id if they have values + kwargs: dict[str, Any] = { + "content": msg["content"], + "role": cast(MessageRole, msg["role"]), + "name": msg.get("name"), + "model_name": msg.get("model"), + "cost_info": cost_info, + "usage": TypeAdapter(RunUsage).validate_python(msg["token_usage"]) + if msg["token_usage"] + else RunUsage(), + "response_time": msg.get("response_time"), + "parent_id": msg.get("parent_id"), + "session_id": msg.get("session_id"), + "messages": deserialize_messages(msg.get("messages")), + "finish_reason": msg.get("finish_reason"), + } + if msg.get("timestamp"): + kwargs["timestamp"] = datetime.fromisoformat(msg["timestamp"]) + if msg.get("message_id"): + kwargs["message_id"] = msg["message_id"] + return ChatMessage[str](**kwargs) diff --git a/src/agentpool_storage/formatters.py b/src/agentpool_storage/formatters.py index c4d3c91f8..43995c351 100644 --- a/src/agentpool_storage/formatters.py +++ b/src/agentpool_storage/formatters.py @@ -70,9 +70,9 @@ def _print_conversation(console: Console, conv: ConversationData) -> None: if token_usage := conv.get("token_usage"): console.print( "[dim]" - f"Tokens: {token_usage['total']:,} total " - f"({token_usage['prompt']:,} prompt, " - f"{token_usage['completion']:,} completion)" + f"Tokens: {token_usage.total_tokens:,} total " + f"({token_usage.input_tokens:,} prompt, " + f"{token_usage.output_tokens:,} completion)" "[/]" ) console.print() @@ -97,7 +97,14 @@ def _print_stats(console: Console, stats: dict[str, Any]) -> None: for entry in stats.get("entries", [stats]): console.print(f"[blue]{entry['name']}[/]") console.print(f" Messages: {entry['messages']}") - console.print(f" Total tokens: {entry['total_tokens']:,}") + usage = entry["usage"] + console.print( + f" Tokens: {usage.total_tokens:,} total" + f" ({usage.input_tokens:,} input, {usage.output_tokens:,} output" + + (f", {usage.cache_read_tokens:,} cache read" if usage.cache_read_tokens else "") + + (f", {usage.cache_write_tokens:,} cache write" if usage.cache_write_tokens else "") + + ")" + ) if "models" in entry: console.print(" Models: " + ", ".join(entry["models"])) console.print() @@ -118,7 +125,7 @@ def format_stats(stats: dict[str, dict[str, Any]], period: str, group_by: str) - { "name": key, "messages": data["messages"], - "total_tokens": data["total_tokens"], + "usage": data["usage"], "models": sorted(data["models"]), } for key, data in stats.items() diff --git a/src/agentpool_storage/memory_provider/provider.py b/src/agentpool_storage/memory_provider/provider.py index 43b59d9cc..330899768 100644 --- a/src/agentpool_storage/memory_provider/provider.py +++ b/src/agentpool_storage/memory_provider/provider.py @@ -5,6 +5,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any +from pydantic_ai import RunUsage + from agentpool.utils.time_utils import get_now from agentpool_config.storage import MemoryStorageConfig from agentpool_storage.base import StorageProvider @@ -18,7 +20,7 @@ from agentpool.messaging import ChatMessage from agentpool.sessions.models import ProjectData, SessionData from agentpool_config.session import SessionQuery - from agentpool_storage.models import QueryFilters, StatsFilters, TokenUsage + from agentpool_storage.models import QueryFilters, StatsFilters class MemoryStorageProvider(StorageProvider): @@ -298,7 +300,7 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: title=conv.get("title"), start_time=conv["start_time"].isoformat(), messages=conv_messages, - token_usage=self._aggregate_token_usage(conv_messages), + token_usage=_aggregate_token_usage(conv_messages), ) results.append(conv_data) if filters.limit and len(results) >= filters.limit: @@ -315,22 +317,11 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, continue if filters.agent_name and msg.name != filters.agent_name: continue - rows.append((msg.model_name, msg.name, msg.timestamp, msg.cost_info)) + rows.append((msg.model_name, msg.name, msg.timestamp, msg.usage)) # Use base class aggregation return self.aggregate_stats(rows, filters.group_by) - @staticmethod - def _aggregate_token_usage(messages: Sequence[ChatMessage[Any]]) -> TokenUsage: - """Sum up tokens from a sequence of messages.""" - total = prompt = completion = 0 - for msg in messages: - if msg.cost_info: - total += msg.cost_info.token_usage.total_tokens - prompt += msg.cost_info.token_usage.input_tokens - completion += msg.cost_info.token_usage.output_tokens - return {"total": total, "prompt": prompt, "completion": completion} - async def reset(self, *, agent_name: str | None = None, hard: bool = False) -> tuple[int, int]: """Reset stored data.""" # Get counts first @@ -490,13 +481,17 @@ async def list_session_ids( result.append(conv["id"]) return result - async def update_sdk_session_id( - self, - session_id: str, - sdk_session_id: str, - ) -> None: + async def update_sdk_session_id(self, session_id: str, sdk_session_id: str) -> None: """Update the external SDK session ID in memory.""" for conv in self.conversations: if conv["id"] == session_id: conv["sdk_session_id"] = sdk_session_id return + + +def _aggregate_token_usage(messages: Sequence[ChatMessage[Any]]) -> RunUsage: + """Sum up tokens from a sequence of messages.""" + usage = RunUsage() + for msg in messages: + usage += msg.usage + return usage diff --git a/src/agentpool_storage/models.py b/src/agentpool_storage/models.py index 0488507d5..55489e595 100644 --- a/src/agentpool_storage/models.py +++ b/src/agentpool_storage/models.py @@ -9,23 +9,14 @@ if TYPE_CHECKING: from datetime import datetime + from pydantic_ai import RunUsage + from agentpool.messaging import ChatMessage GroupBy = Literal["agent", "model", "hour", "day"] -class TokenUsage(TypedDict): - """Token usage statistics from model responses.""" - - total: int - """Total tokens used""" - prompt: int - """Tokens used in the prompt""" - completion: int - """Tokens used in the completion""" - - class ConversationData(TypedDict): """Formatted conversation data.""" @@ -44,7 +35,7 @@ class ConversationData(TypedDict): messages: list[ChatMessage[Any]] """List of messages in this conversation""" - token_usage: TokenUsage | None + token_usage: RunUsage | None """Aggregated token usage for the entire conversation""" diff --git a/src/agentpool_storage/opencode_provider/ARCHITECTURE.md b/src/agentpool_storage/opencode_provider/ARCHITECTURE.md deleted file mode 100644 index 2b2ea30f7..000000000 --- a/src/agentpool_storage/opencode_provider/ARCHITECTURE.md +++ /dev/null @@ -1,386 +0,0 @@ -# OpenCode Storage Architecture - -This document explains how OpenCode persists conversation data to the filesystem. - -## Directory Structure - -``` -~/.local/share/opencode/ -├── storage/ -│ ├── message/ # Message metadata by session -│ │ └── {sessionID}/ -│ │ └── {messageID}.json # Message metadata -│ ├── part/ # Message content parts -│ │ └── {messageID}/ -│ │ └── {partID}.json # Content blocks (text, tool_use, etc.) -│ ├── session/ # Session metadata by project -│ │ ├── global/ # Sessions not tied to a project -│ │ │ └── {sessionID}.json # Session metadata -│ │ └── {projectID}/ # Project-specific sessions -│ │ └── {sessionID}.json # Session metadata -│ ├── session_diff/ # Session diffs (unused?) -│ ├── session_share/ # Shared sessions (unused?) -│ └── project/ # Project metadata -├── snapshot/ # Project snapshots -├── log/ # Application logs -└── auth.json # Authentication credentials -``` - -## Core Concepts - -### 1. Storage Model: Normalized Database on Filesystem - -OpenCode uses a **normalized relational model** stored as JSON files: -- **Sessions** → Metadata about conversations -- **Messages** → Message-level metadata (role, time, agent) -- **Parts** → Content blocks within messages (text, tool_use, tool_result) - -This is fundamentally different from Claude Code's append-only JSONL approach. - -### 2. ID Format - -All IDs use a custom format with prefixes: -- **Session**: `ses_{random}` (e.g., `ses_4afbda00cffeVl5YERm4op7JEG`) -- **Message**: `msg_{random}` (e.g., `msg_b50425ff7001vgFiMUNFzLtCda`) -- **Part**: `prt_{random}` (e.g., `prt_b50425ff7002egcP330jM5wQcU`) -- **Project**: SHA1 hash of directory path (e.g., `486ce75a8fddd4372018ab816ac62d8004dc52fd`) - -The random portion appears to be a timestamp-based identifier. - -### 3. Projects - -**Project ID**: SHA1 hash of the absolute directory path - -Example: -```bash -echo -n "/home/phil65/dev/oss/agentpool" | sha1sum -# → 486ce75a8fddd4372018ab816ac62d8004dc52fd -``` - -**Special Project**: `global` -- Used for sessions not tied to a specific directory -- Sessions created from home directory or no working directory - -**Storage**: -- Session files: `storage/session/{projectID}/{sessionID}.json` -- Global sessions: `storage/session/global/{sessionID}.json` - -### 4. Sessions - -**Session File**: `storage/session/{projectID}/{sessionID}.json` - -```json -{ - "id": "ses_4afbda00cffeVl5YERm4op7JEG", - "version": "1.0.193", - "projectID": "486ce75a8fddd4372018ab816ac62d8004dc52fd", - "directory": "/home/phil65/dev/oss/agentpool", - "title": "Claude capabilities overview", - "time": { - "created": 1766578085876, - "updated": 1766578154946 - }, - "summary": { - "additions": 0, - "deletions": 0, - "files": 0 - } -} -``` - -**Fields**: -- `id`: Session identifier -- `version`: OpenCode version that created the session -- `projectID`: SHA1 hash of directory or "global" -- `directory`: Absolute path to working directory -- `title`: Auto-generated summary of session topic -- `time.created`: Unix timestamp (milliseconds) -- `time.updated`: Unix timestamp (milliseconds) -- `summary`: File change statistics - -### 5. Messages - -**Message File**: `storage/message/{sessionID}/{messageID}.json` - -```json -{ - "id": "msg_b50425ff7001vgFiMUNFzLtCda", - "sessionID": "ses_4afbda00cffeVl5YERm4op7JEG", - "role": "user", - "time": { - "created": 1766578085884 - }, - "summary": { - "title": "Exploring available tools", - "diffs": [] - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-opus-4-5-20251101" - } -} -``` - -**Fields**: -- `id`: Message identifier -- `sessionID`: Parent session -- `role`: "user" | "assistant" -- `time.created`: Unix timestamp (milliseconds) -- `summary.title`: Auto-generated message summary -- `summary.diffs`: File changes in this message -- `agent`: Agent name (e.g., "build", custom agent names) -- `model`: Model configuration (for assistant messages) - - `providerID`: "anthropic", "openai", etc. - - `modelID`: Full model identifier - -### 6. Parts (Message Content) - -**Part File**: `storage/part/{messageID}/{partID}.json` - -Parts represent the actual content blocks within a message. - -#### Text Part -```json -{ - "id": "prt_b50425ff7002egcP330jM5wQcU", - "sessionID": "ses_4afbda00cffeVl5YERm4op7JEG", - "messageID": "msg_b50425ff7001vgFiMUNFzLtCda", - "type": "text", - "text": "what tools do you have?" -} -``` - -#### Tool Use Part -```json -{ - "id": "prt_...", - "sessionID": "ses_...", - "messageID": "msg_...", - "type": "tool_use", - "name": "read_file", - "input": { - "path": "src/main.py" - } -} -``` - -#### Tool Result Part -```json -{ - "id": "prt_...", - "sessionID": "ses_...", - "messageID": "msg_...", - "type": "tool_result", - "tool_use_id": "toolu_...", - "content": "file contents here..." -} -``` - -**Part Types**: -- `text`: Text content -- `tool_use`: Tool invocation -- `tool_result`: Tool execution result -- `thinking`: Claude's thinking process (extended thinking) -- `image`: Image content (base64 or URL) - -### 7. Message Flow - -Unlike Claude Code's linked list, OpenCode doesn't store parent-child relationships explicitly in the storage layer. The conversation flow is determined by: - -1. **Message order**: Files in `storage/message/{sessionID}/` directory -2. **Timestamp**: `time.created` field determines chronological order -3. **No parent references**: Must reconstruct flow from timestamps - -To read a conversation: -```python -# 1. List all message files in session directory -messages = list_files(f"storage/message/{session_id}/") - -# 2. Read each message -for msg_file in messages: - msg = load_json(msg_file) - parts = load_parts(f"storage/part/{msg['id']}/") - # Combine message + parts -``` - -## Key Differences from Claude Code - -| Aspect | OpenCode | Claude Code | -|--------|----------|-------------| -| **Format** | Normalized JSON files | Append-only JSONL | -| **Structure** | Relational (sessions → messages → parts) | Linear log with parent refs | -| **Message Flow** | Timestamp-based ordering | Explicit parent-child links | -| **Updates** | Files can be updated in place | Append-only, immutable | -| **Branches** | Not supported | Sidechains with `isSidechain` flag | -| **Projects** | SHA1 hash of directory | URL-encoded path | -| **IDs** | Custom prefixed format | UUIDs or short hex | -| **Content** | Separated into parts | Inline in message entry | - -## Storage Provider Implementation - -### Key Responsibilities - -1. **Path Management** - - Hash directory paths to project IDs - - Organize sessions by project - - Handle "global" project for unscoped sessions - -2. **Message Reconstruction** - - Load message metadata from `message/` directory - - Load content parts from `part/` directory - - Combine into unified message representation - -3. **Conversation Queries** - - List sessions for a project - - Get message count and statistics - - Retrieve messages in chronological order - -4. **Format Conversion** - - OpenCode format → `ChatMessage` (for agentpool) - - `ChatMessage` → OpenCode format (for persistence) - -### Challenges - -1. **No Parent Links** - - Cannot trace message ancestry efficiently - - Must rely on timestamps for ordering - - Forking/branching not supported - -2. **Scattered Data** - - Each message requires multiple file reads - - Parts are in separate directories - - No atomic transactions across files - -3. **No Versioning** - - Files can be updated in place - - No history of edits - - No way to detect concurrent modifications - -## Data Consistency - -### File Organization -- Messages grouped by session in directories -- Parts grouped by message in directories -- Sessions grouped by project in directories - -### Atomic Operations -- Individual JSON file writes are atomic -- No atomicity across multiple files -- No transaction support - -### Concurrent Access -- No locking mechanism -- Last write wins on conflicts -- Reading while writing may see partial state - -## Integration Points - -### With Agentpool -- Implements `StorageProvider` protocol -- Converts between OpenCode format and domain models -- Enables conversation persistence - -### Missing Features -- **Todos/Plans**: No built-in todo tracking -- **File History**: Separate from message storage -- **Branching**: No conversation forking support -- **Ancestry**: No parent-child relationships - -## Usage Patterns - -### Reading a Session -```python -provider = OpenCodeStorageProvider() - -# 1. Get session metadata -session_file = f"~/.local/share/opencode/storage/session/{project_id}/{session_id}.json" -session = load_json(session_file) - -# 2. List messages in session -message_dir = f"~/.local/share/opencode/storage/message/{session_id}/" -message_files = list_files(message_dir) - -# 3. Load each message + parts -messages = [] -for msg_file in sorted(message_files): # Sort by timestamp in ID - msg = load_json(msg_file) - - # Load parts - part_dir = f"~/.local/share/opencode/storage/part/{msg['id']}/" - parts = [load_json(p) for p in list_files(part_dir)] - - messages.append(combine(msg, parts)) -``` - -### Writing a Message -```python -# 1. Create message metadata -message = { - "id": f"msg_{generate_id()}", - "sessionID": session_id, - "role": "user", - "time": {"created": time_ms()}, - "summary": {"title": "...", "diffs": []}, - "agent": "default", -} -write_json(f"storage/message/{session_id}/{message['id']}.json", message) - -# 2. Create parts -for part in content_parts: - part_data = { - "id": f"prt_{generate_id()}", - "sessionID": session_id, - "messageID": message['id'], - "type": part['type'], - **part['data'] - } - write_json(f"storage/part/{message['id']}/{part_data['id']}.json", part_data) -``` - -## Design Rationale - -### Why Normalized Structure? -- **Flexibility**: Can update individual components -- **Modularity**: Parts can be processed independently -- **Extensibility**: Easy to add new part types - -### Why Separate Parts? -- **Streaming**: Can load message metadata without content -- **Lazy Loading**: Only load parts when needed -- **Type Safety**: Each part type has specific schema - -### Why SHA1 for Project ID? -- **Deterministic**: Same path always gives same ID -- **Collision-resistant**: Very unlikely hash collisions -- **Path-independent**: ID doesn't reveal directory structure - -### Drawbacks -- **Performance**: Many small files, lots of I/O -- **Consistency**: No atomic multi-file operations -- **Complexity**: More complex than append-only log -- **No History**: Can't track conversation evolution - -## Future Considerations - -### Performance -- Consider SQLite for better query performance -- Index sessions by project and timestamp -- Cache frequently accessed metadata - -### Consistency -- Implement write-ahead logging -- Add transaction support -- Version individual files - -### Features -- Add parent-child message links -- Support conversation branching -- Track message edit history -- Implement proper locking - ---- - -**Related Files:** -- Implementation: [`provider.py`](./provider.py) -- Base Protocol: [`../base.py`](../base.py) diff --git a/src/agentpool_storage/opencode_provider/helpers.py b/src/agentpool_storage/opencode_provider/helpers.py index dcb50c434..528ef948b 100644 --- a/src/agentpool_storage/opencode_provider/helpers.py +++ b/src/agentpool_storage/opencode_provider/helpers.py @@ -6,16 +6,13 @@ from __future__ import annotations -import base64 from decimal import Decimal -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from pydantic import TypeAdapter from pydantic_ai import ( BinaryContent, ModelRequest, ModelResponse, - RequestUsage, RunUsage, TextPart as PydanticTextPart, ThinkingPart, @@ -28,135 +25,40 @@ from agentpool.messaging import ChatMessage, TokenCost from agentpool.utils.pydantic_ai_helpers import to_user_content from agentpool.utils.time_utils import ms_to_datetime -from agentpool_server.opencode_server.models.message import ( +from opencode_sdk.helpers import extract_text_content +from opencode_sdk.models import ( AssistantMessage, - MessageInfo, - UserMessage, -) -from agentpool_server.opencode_server.models.parts import ( FilePart, - Part, ReasoningPart, TextPart, ToolPart, ToolStateCompleted, + UserMessage, ) if TYPE_CHECKING: from datetime import datetime - from pydantic_ai.messages import UserContent - - -logger = get_logger(__name__) - -_message_info_adapter: TypeAdapter[MessageInfo] = TypeAdapter(MessageInfo) -_part_adapter: TypeAdapter[Part] = TypeAdapter(Part) - - -def parse_message_info(data: dict[str, Any], *, message_id: str, session_id: str) -> MessageInfo: - """Parse a message JSON data dict into a typed MessageInfo model. - - Injects the DB column fields (id, sessionID) into the data dict before - validation, matching how OpenCode itself reconstructs messages from DB rows. - - Args: - data: The JSON 'data' field from the message table - message_id: Message ID from the DB id column - session_id: Session ID from the DB session_id column - - Returns: - Validated UserMessage or AssistantMessage - """ - data["id"] = message_id - data["sessionID"] = session_id - return _message_info_adapter.validate_python(data) - - -def parse_part(data: dict[str, Any], *, part_id: str, message_id: str, session_id: str) -> Part: - """Parse a part JSON data dict into a typed Part model. - - Injects the DB column fields (id, messageID, sessionID) into the data dict - before validation, matching how OpenCode itself reconstructs parts from DB rows. - - Args: - data: The JSON 'data' field from the part table - part_id: Part ID from the DB id column - message_id: Message ID from the DB message_id column - session_id: Session ID from the DB session_id column - - Returns: - Validated Part (TextPart, ToolPart, ReasoningPart, etc.) - """ - data["id"] = part_id - data["messageID"] = message_id - data["sessionID"] = session_id - return _part_adapter.validate_python(data) + from pydantic_ai import ModelMessage, UserContent + from opencode_sdk.models import MessageWithParts, Part -def extract_text_content(parts: list[Part]) -> str: - """Extract text content from typed parts for display. - Groups consecutive reasoning parts into a single block - and only wraps them if there are also non-reasoning parts present. - - Args: - parts: List of typed Part models - - Returns: - Combined text content from all text and reasoning parts - """ - text_segments: list[str] = [] - reasoning_segments: list[str] = [] - has_text = False - - for part in parts: - if isinstance(part, TextPart): - if part.text: - has_text = True - # Flush any accumulated reasoning before this text - if reasoning_segments: - combined = "\n".join(reasoning_segments) - text_segments.append(f"\n{combined}\n") - reasoning_segments.clear() - text_segments.append(part.text) - elif isinstance(part, ReasoningPart) and part.text: - reasoning_segments.append(part.text) - - # Flush remaining reasoning - if reasoning_segments: - combined = "\n".join(reasoning_segments) - if has_text: - text_segments.append(f"\n{combined}\n") - else: - # Entire message is thinking — no need for wrapper tags - text_segments.append(combined) - - return "\n".join(text_segments) +logger = get_logger(__name__) -def _build_user_pydantic_messages( - parts: list[Part], - timestamp: datetime, -) -> list[ModelRequest | ModelResponse]: +def _build_user_pydantic_messages(parts: list[Part], timestamp: datetime) -> list[ModelMessage]: """Build ModelRequest from user message parts.""" user_content: list[UserContent] = [] for part in parts: - if isinstance(part, TextPart): - if part.text: - user_content.append(part.text) - elif isinstance(part, FilePart): - url = part.url - mime = part.mime - if url.startswith("data:") and ";base64," in url: - mime_part, b64_data = url.split(";base64,", 1) - media_type = mime_part.replace("data:", "") - data = base64.b64decode(b64_data) - user_content.append(BinaryContent(data=data, media_type=media_type)) - elif url: - content_item = to_user_content(url, mime) - user_content.append(content_item) + match part: + case TextPart(text=text) if text: + user_content.append(text) + case FilePart(url=url) if url.startswith("data:") and ";base64," in url: + user_content.append(BinaryContent.from_data_uri(url)) + case FilePart(url=url, mime=mime) if url: + user_content.append(to_user_content(url, mime)) if user_content: user_part = UserPromptPart(content=user_content, timestamp=timestamp) return [ModelRequest(parts=[user_part], timestamp=timestamp)] @@ -167,49 +69,34 @@ def _build_assistant_pydantic_messages( msg: AssistantMessage, parts: list[Part], timestamp: datetime, -) -> list[ModelRequest | ModelResponse]: +) -> list[ModelMessage]: """Build ModelResponse (+ optional ModelRequest for tool returns) from assistant parts.""" - result: list[ModelRequest | ModelResponse] = [] + result: list[ModelMessage] = [] response_parts: list[PydanticTextPart | ToolCallPart | ThinkingPart] = [] tool_return_parts: list[ToolReturnPart] = [] - tokens = msg.tokens - cache = tokens.cache - usage = RequestUsage( - input_tokens=tokens.input, - output_tokens=tokens.output, - cache_read_tokens=cache.read, - cache_write_tokens=cache.write, - ) - for part in parts: - if isinstance(part, TextPart): - if part.text: - response_parts.append(PydanticTextPart(content=part.text)) - elif isinstance(part, ReasoningPart): - if part.text: - response_parts.append(ThinkingPart(content=part.text)) - elif isinstance(part, ToolPart): - tc_part = ToolCallPart( - tool_name=part.tool, - args=part.state.input, - tool_call_id=part.call_id, - ) - response_parts.append(tc_part) - - if isinstance(part.state, ToolStateCompleted) and part.state.output: - return_part = ToolReturnPart( - tool_name=part.tool, - content=part.state.output, - tool_call_id=part.call_id, - timestamp=timestamp, - ) - tool_return_parts.append(return_part) + match part: + case TextPart(text=text) if text: + response_parts.append(PydanticTextPart(content=text)) + case ReasoningPart(text=text) if text: + response_parts.append(ThinkingPart(content=text)) + case ToolPart(tool=tool, call_id=call_id, state=state): + tc_part = ToolCallPart(tool_name=tool, args=state.input, tool_call_id=call_id) + response_parts.append(tc_part) + if isinstance(state, ToolStateCompleted) and state.output: + tr_part = ToolReturnPart( + tool_name=tool, + content=state.output, + tool_call_id=call_id, + timestamp=timestamp, + ) + tool_return_parts.append(tr_part) if response_parts: model_response = ModelResponse( parts=response_parts, - usage=usage, + usage=msg.tokens.to_request_usage(), model_name=msg.model_id, timestamp=timestamp, ) @@ -221,82 +108,54 @@ def _build_assistant_pydantic_messages( return result -def build_pydantic_messages( - msg: MessageInfo, - parts: list[Part], - timestamp: datetime, -) -> list[ModelRequest | ModelResponse]: - """Build pydantic-ai messages from typed OpenCode models. - - In OpenCode's model, assistant messages contain both tool calls AND their - results in the same message. We split these into: - - ModelResponse with ToolCallPart (the call) - - ModelRequest with ToolReturnPart (the result) - - Args: - msg: Typed UserMessage or AssistantMessage - parts: List of typed Part models - timestamp: Message timestamp - - Returns: - List of pydantic-ai messages (ModelRequest and/or ModelResponse) - """ - if isinstance(msg, UserMessage): - return _build_user_pydantic_messages(parts, timestamp) - return _build_assistant_pydantic_messages(msg, parts, timestamp) - - -def to_chat_message( - *, - msg: MessageInfo, - parts: list[Part], -) -> ChatMessage[str]: +def to_chat_message(message: MessageWithParts) -> ChatMessage[str]: """Convert typed OpenCode message + parts to ChatMessage. Args: - msg: Typed UserMessage or AssistantMessage - parts: List of typed Part models + message: Message (with parts) Returns: ChatMessage with content, pydantic messages, cost info etc. """ - timestamp = ms_to_datetime(msg.time.created) - content = extract_text_content(parts) - pydantic_messages = build_pydantic_messages(msg, parts, timestamp) - - cost_info = None - provider_details: dict[str, Any] = {} - parent_id: str | None = None - model_name: str | None = None - agent_name: str | None = msg.agent if msg.agent != "default" else None - - if isinstance(msg, AssistantMessage): - tokens = msg.tokens - cache = tokens.cache - input_tokens = tokens.input + cache.read - output_tokens = tokens.output - if input_tokens or output_tokens: - usage = RunUsage(input_tokens=input_tokens, output_tokens=output_tokens) - cost = Decimal(str(msg.cost)) - cost_info = TokenCost(token_usage=usage, total_cost=cost) - if msg.finish: - provider_details["finish_reason"] = msg.finish - parent_id = msg.parent_id - model_name = msg.model_id - agent_name = msg.agent if msg.agent != "default" else None - elif isinstance(msg, UserMessage) and msg.model is not None: - model_name = msg.model.model_id + from agentpool_server.opencode_server.converters import to_native_finish_reason - return ChatMessage[str]( - content=content, - session_id=msg.session_id, - role=msg.role, - message_id=msg.id, - name=agent_name, - model_name=model_name, - cost_info=cost_info, - timestamp=timestamp, - parent_id=parent_id, - messages=pydantic_messages, - provider_details=provider_details, - ) + msg = message.info + timestamp = ms_to_datetime(msg.time.created) + content = extract_text_content(message.parts) + agent_name = msg.agent if msg.agent != "default" else None + match msg: + case AssistantMessage( + tokens=tokens, + finish=finish, + cost=cost, + parent_id=parent_id, + model_id=model_name, + id=message_id, + session_id=session_id, + ): + return ChatMessage[str]( + content=content, + session_id=session_id, + role="assistant", + message_id=message_id, + name=agent_name, + model_name=model_name, + cost_info=TokenCost(total_cost=Decimal(str(cost))), + finish_reason=to_native_finish_reason(finish), + usage=tokens.to_run_usage(), + timestamp=timestamp, + parent_id=parent_id, + messages=_build_assistant_pydantic_messages(msg, message.parts, timestamp), + ) + case UserMessage(model=model, id=message_id, session_id=session_id): + return ChatMessage[str]( + content=content, + session_id=session_id, + role="user", + message_id=message_id, + name=agent_name, + model_name=model.model_id, + usage=RunUsage(input_tokens=0, output_tokens=0), + timestamp=timestamp, + messages=_build_user_pydantic_messages(message.parts, timestamp), + ) diff --git a/src/agentpool_storage/opencode_provider/provider.py b/src/agentpool_storage/opencode_provider/provider.py index 14a8fcaae..efe1e5f0c 100644 --- a/src/agentpool_storage/opencode_provider/provider.py +++ b/src/agentpool_storage/opencode_provider/provider.py @@ -3,48 +3,33 @@ This module implements storage compatible with OpenCode's SQLite database format (>= 1.2). The database is typically located at ~/.local/share/opencode/opencode.db. -Schema overview: -- project: id, worktree, vcs, name, ... -- session: id, project_id, parent_id, slug, directory, title, version, ... -- message: id, session_id, time_created, time_updated, data (JSON) -- part: id, message_id, session_id, time_created, time_updated, data (JSON) -- todo: session_id, content, status, priority, position, ... - -Message and part data is stored as JSON text columns. The 'data' field contains -the full message/part payload minus the id and session_id which are separate columns. - -Timestamps are stored as integer milliseconds since epoch. +This provider delegates all SQLite access to OpenCodeStorageClient and converts +the OpenCode SDK models to agentpool ChatMessage / ConversationData types. """ from __future__ import annotations from collections import defaultdict from datetime import datetime -from pathlib import Path -import sqlite3 from typing import TYPE_CHECKING, Any -import anyenv +from pydantic_ai import RunUsage from agentpool.log import get_logger from agentpool.utils.time_utils import datetime_to_ms, get_now, ms_to_datetime from agentpool_config.storage import OpenCodeStorageConfig -from agentpool_server.opencode_server.models.message import ( - AssistantMessage, -) from agentpool_storage.base import StorageProvider -from agentpool_storage.models import ConversationData as ConvData, TokenUsage +from agentpool_storage.models import ConversationData as ConvData from agentpool_storage.opencode_provider import helpers +from opencode_sdk.models import AssistantMessage +from opencode_sdk.storage_client import OpenCodeStorageClient if TYPE_CHECKING: from agentpool.messaging import ChatMessage from agentpool_config.session import SessionQuery - from agentpool_server.opencode_server.models.message import ( - MessageInfo, - ) - from agentpool_server.opencode_server.models.parts import Part from agentpool_storage.models import QueryFilters, StatsFilters + from opencode_sdk.models import MessageWithParts logger = get_logger(__name__) @@ -52,16 +37,9 @@ class OpenCodeStorageProvider(StorageProvider): """Storage provider that reads OpenCode's native SQLite format. - OpenCode (>= 1.2) stores data in a single SQLite database: - - ~/.local/share/opencode/opencode.db - - Tables: - - project: project/worktree metadata - - session: conversation sessions linked to projects - - message: messages with JSON data column - - part: message parts with JSON data column - This is primarily a READ-ONLY provider for importing OpenCode history. + All SQLite access is delegated to OpenCodeStorageClient; this class + only converts between OpenCode models and agentpool types. """ can_load_history = True @@ -70,161 +48,34 @@ def __init__(self, config: OpenCodeStorageConfig | None = None) -> None: """Initialize OpenCode SQLite storage provider.""" config = config or OpenCodeStorageConfig() super().__init__(config) - self.db_path = Path(config.path).expanduser() - - def _get_connection(self) -> sqlite3.Connection: - """Get a SQLite connection with row factory.""" - if not self.db_path.exists(): - raise FileNotFoundError(f"OpenCode database not found: {self.db_path}") - conn = sqlite3.connect(str(self.db_path)) - conn.row_factory = sqlite3.Row - return conn - - def _read_message_rows(self, session_id: str) -> list[sqlite3.Row]: - """Read all message rows for a session, ordered by time_created.""" - try: - conn = self._get_connection() - except FileNotFoundError: - return [] - try: - cursor = conn.execute( - "SELECT id, session_id, time_created, time_updated, data " - "FROM message WHERE session_id = ? ORDER BY time_created ASC", - (session_id,), - ) - return cursor.fetchall() - finally: - conn.close() - - def _parse_message(self, row: sqlite3.Row) -> MessageInfo: - """Parse a message DB row into a typed MessageInfo model. - - Injects id and session_id from the row columns into the JSON data - before validation, matching OpenCode's own reconstruction pattern. - """ - data = anyenv.load_json(row["data"], return_type=dict) - return helpers.parse_message_info(data, message_id=row["id"], session_id=row["session_id"]) - - def _read_parts_for_session(self, session_id: str) -> dict[str, list[Part]]: - """Read all parts for a session, grouped by message_id. - - Returns: - Dict mapping message_id -> list of typed Part models - """ - try: - conn = self._get_connection() - except FileNotFoundError: - return {} - try: - cursor = conn.execute( - "SELECT id, message_id, session_id, data " - "FROM part WHERE session_id = ? ORDER BY message_id, id ASC", - (session_id,), - ) - result: dict[str, list[Part]] = defaultdict(list) - for row in cursor: - data = anyenv.load_json(row["data"], return_type=dict) - try: - part = helpers.parse_part( - data, - part_id=row["id"], - message_id=row["message_id"], - session_id=row["session_id"], - ) - result[row["message_id"]].append(part) - except Exception: # noqa: BLE001 - logger.debug( - "Failed to parse part, skipping", - part_id=row["id"], - part_type=data.get("type", "unknown"), - ) - return result - finally: - conn.close() - - def _read_parts_for_message(self, message_id: str) -> list[Part]: - """Read all parts for a message, ordered by id.""" - try: - conn = self._get_connection() - except FileNotFoundError: - return [] - try: - cursor = conn.execute( - "SELECT id, message_id, session_id, data " - "FROM part WHERE message_id = ? ORDER BY id ASC", - (message_id,), - ) - parts: list[Part] = [] - for row in cursor: - data = anyenv.load_json(row["data"], return_type=dict) - try: - part = helpers.parse_part( - data, - part_id=row["id"], - message_id=row["message_id"], - session_id=row["session_id"], - ) - parts.append(part) - except Exception: # noqa: BLE001 - logger.debug( - "Failed to parse part, skipping", - part_id=row["id"], - part_type=data.get("type", "unknown"), - ) - return parts - finally: - conn.close() + self.client = OpenCodeStorageClient(db_path=config.path) async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]: """Filter messages based on query.""" messages: list[ChatMessage[str]] = [] - try: - conn = self._get_connection() - except FileNotFoundError: - return [] - try: - # Build session query - if query.name: - session_rows = conn.execute( - "SELECT id FROM session WHERE id = ?", (query.name,) - ).fetchall() - else: - session_rows = conn.execute("SELECT id FROM session").fetchall() - - for session_row in session_rows: - session_id: str = session_row["id"] - msg_rows = conn.execute( - "SELECT id, session_id, time_created, time_updated, data " - "FROM message WHERE session_id = ? ORDER BY time_created ASC", - (session_id,), - ).fetchall() - - parts_by_msg = self._read_parts_for_session(session_id) - for msg_row in msg_rows: - msg_id: str = msg_row["id"] - msg = self._parse_message(msg_row) - parts = parts_by_msg.get(msg_id, []) - chat_msg = helpers.to_chat_message(msg=msg, parts=parts) - # Apply filters - if query.agents and chat_msg.name not in query.agents: - continue - cutoff = query.get_time_cutoff() - if query.since and cutoff and chat_msg.timestamp < cutoff: - continue - if query.until: - until_dt = datetime.fromisoformat(query.until) - if chat_msg.timestamp > until_dt: - continue - if query.contains and query.contains not in chat_msg.content: - continue - if query.roles and chat_msg.role not in query.roles: + session_ids = self.client.get_session_ids(name=query.name) + + for session_id in session_ids: + session_msgs = self.client.get_session_messages(session_id) + for mwp in session_msgs: + chat_msg = helpers.to_chat_message(mwp) + # Apply filters + if query.agents and chat_msg.name not in query.agents: + continue + cutoff = query.get_time_cutoff() + if query.since and cutoff and chat_msg.timestamp < cutoff: + continue + if query.until: + until_dt = datetime.fromisoformat(query.until) + if chat_msg.timestamp > until_dt: continue - messages.append(chat_msg) - - if query.limit and len(messages) >= query.limit: - return messages - finally: - conn.close() + if query.contains and query.contains not in chat_msg.content: + continue + if query.roles and chat_msg.role not in query.roles: + continue + messages.append(chat_msg) + if query.limit and len(messages) >= query.limit: + return messages return messages @@ -246,132 +97,72 @@ async def log_session( async def get_sessions(self, filters: QueryFilters) -> list[ConvData]: """Get filtered conversations with their messages.""" result: list[ConvData] = [] - try: - conn = self._get_connection() - except FileNotFoundError: - return [] - try: - # Build SQL conditions - conditions: list[str] = [] - params: list[Any] = [] - - if filters.since: - since_ms = datetime_to_ms(filters.since) - conditions.append("s.time_created >= ?") - params.append(since_ms) - - where = f" WHERE {' AND '.join(conditions)}" if conditions else "" - sql = ( - f"SELECT s.id, s.title, s.time_created, s.time_updated, s.project_id " - f"FROM session s{where} ORDER BY s.time_updated DESC" + since_ms = datetime_to_ms(filters.since) if filters.since else None + # Over-fetch since we filter more below + limit = filters.limit * 2 if filters.limit else None + sessions = self.client.get_sessions(since_ms=since_ms, limit=limit) + + for session in sessions: + session_msgs = self.client.get_session_messages(session.id) + if not session_msgs: + continue + + chat_messages: list[ChatMessage[str]] = [] + usage = RunUsage() + for mwp in session_msgs: + chat_msg = helpers.to_chat_message(mwp) + chat_messages.append(chat_msg) + if isinstance(mwp.info, AssistantMessage): + usage.incr(mwp.info.tokens.to_run_usage()) + # Apply remaining filters + if filters.agent_name and not any(m.name == filters.agent_name for m in chat_messages): + continue + if filters.query and not any(filters.query in m.content for m in chat_messages): + continue + + conv_data = ConvData( + id=session.id, + agent=chat_messages[0].name or "opencode", + title=session.title, + start_time=ms_to_datetime(session.time.created).isoformat(), + messages=chat_messages, + token_usage=usage, ) - if filters.limit: - sql += " LIMIT ?" - params.append(filters.limit * 2) # Over-fetch since we filter more below - - for session_row in conn.execute(sql, params).fetchall(): - session_id: str = session_row["id"] - title: str = session_row["title"] - time_created: int = session_row["time_created"] - - # Read messages for this session - msg_rows = conn.execute( - "SELECT id, session_id, time_created, time_updated, data " - "FROM message WHERE session_id = ? ORDER BY time_created ASC", - (session_id,), - ).fetchall() - - if not msg_rows: - continue - - parts_by_msg = self._read_parts_for_session(session_id) - chat_messages: list[ChatMessage[str]] = [] - total_tokens = 0 - for msg_row in msg_rows: - msg_id: str = msg_row["id"] - msg = self._parse_message(msg_row) - parts = parts_by_msg.get(msg_id, []) - chat_msg = helpers.to_chat_message(msg=msg, parts=parts) - chat_messages.append(chat_msg) - # Count tokens from assistant messages - if isinstance(msg, AssistantMessage): - total_tokens += msg.tokens.input + msg.tokens.output - - if not chat_messages: - continue - - # Apply remaining filters - if filters.agent_name and not any( - m.name == filters.agent_name for m in chat_messages - ): - continue - if filters.query and not any(filters.query in m.content for m in chat_messages): - continue - - usage = ( - TokenUsage(total=total_tokens, prompt=0, completion=0) if total_tokens else None - ) - conv_data = ConvData( - id=session_id, - agent=chat_messages[0].name or "opencode", - title=title, - start_time=ms_to_datetime(time_created).isoformat(), - messages=chat_messages, - token_usage=usage, - ) - result.append(conv_data) - if filters.limit and len(result) >= filters.limit: - break - finally: - conn.close() + result.append(conv_data) + if filters.limit and len(result) >= filters.limit: + break return result async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, Any]]: """Get conversation statistics.""" stats: dict[str, dict[str, Any]] = defaultdict( - lambda: {"total_tokens": 0, "messages": 0, "models": set(), "total_cost": 0.0} + lambda: {"usage": RunUsage(), "messages": 0, "models": set(), "total_cost": 0.0} ) - try: - conn = self._get_connection() - except FileNotFoundError: - return {} - try: - cutoff_ms = datetime_to_ms(filters.cutoff) - - # Query messages with their data, filtered by time - cursor = conn.execute( - "SELECT m.id, m.session_id, m.time_created, m.data " - "FROM message m " - "JOIN session s ON m.session_id = s.id " - "WHERE s.time_created >= ?", - (cutoff_ms,), - ) - - for row in cursor: - msg = self._parse_message(row) - if not isinstance(msg, AssistantMessage): - continue - - tokens = msg.tokens.input + msg.tokens.output - msg_timestamp = ms_to_datetime(row["time_created"]) - - match filters.group_by: - case "model": - key = msg.model_id - case "hour": - key = msg_timestamp.strftime("%Y-%m-%d %H:00") - case "day": - key = msg_timestamp.strftime("%Y-%m-%d") - case _: - key = msg.agent if msg.agent != "default" else "opencode" - - stats[key]["messages"] += 1 - stats[key]["total_tokens"] += tokens - stats[key]["models"].add(msg.model_id) - stats[key]["total_cost"] += msg.cost - finally: - conn.close() + cutoff_ms = datetime_to_ms(filters.cutoff) + messages_with_data = self.client.get_messages_with_data(since_ms=cutoff_ms) + + for mwp in messages_with_data: + msg = mwp.info + if not isinstance(msg, AssistantMessage): + continue + + msg_timestamp = ms_to_datetime(msg.time.created) + + match filters.group_by: + case "model": + key = msg.model_id + case "hour": + key = msg_timestamp.strftime("%Y-%m-%d %H:00") + case "day": + key = msg_timestamp.strftime("%Y-%m-%d") + case _: + key = msg.agent if msg.agent != "default" else "opencode" + + stats[key]["messages"] += 1 + stats[key]["usage"] += msg.tokens.to_run_usage() + stats[key]["models"].add(msg.model_id) + stats[key]["total_cost"] += msg.cost # Convert sets to lists for value in stats.values(): @@ -386,16 +177,7 @@ async def reset(self, *, agent_name: str | None = None, hard: bool = False) -> t async def get_session_counts(self, *, agent_name: str | None = None) -> tuple[int, int]: """Get counts of conversations and messages.""" - try: - conn = self._get_connection() - except FileNotFoundError: - return 0, 0 - try: - session_count: int = conn.execute("SELECT COUNT(*) FROM session").fetchone()[0] - msg_count: int = conn.execute("SELECT COUNT(*) FROM message").fetchone()[0] - return session_count, msg_count - finally: - conn.close() + return self.client.get_session_counts() async def get_session_messages( self, @@ -404,17 +186,8 @@ async def get_session_messages( include_ancestors: bool = False, ) -> list[ChatMessage[str]]: """Get all messages for a session.""" - messages: list[ChatMessage[str]] = [] - msg_rows = self._read_message_rows(session_id) - parts_by_msg = self._read_parts_for_session(session_id) - - for msg_row in msg_rows: - msg_id: str = msg_row["id"] - msg = self._parse_message(msg_row) - parts = parts_by_msg.get(msg_id, []) - - chat_msg = helpers.to_chat_message(msg=msg, parts=parts) - messages.append(chat_msg) + session_msgs = self.client.get_session_messages(session_id) + messages = [helpers.to_chat_message(mwp) for mwp in session_msgs] # Sort by timestamp now = get_now() @@ -436,25 +209,10 @@ async def get_message( session_id: str | None = None, ) -> ChatMessage[str] | None: """Get a single message by ID.""" - try: - conn = self._get_connection() - except FileNotFoundError: + mwp = self.client.get_message(message_id) + if mwp is None: return None - try: - row = conn.execute( - "SELECT id, session_id, time_created, time_updated, data FROM message WHERE id = ?", - (message_id,), - ).fetchone() - - if not row: - return None - - msg = self._parse_message(row) - parts = self._read_parts_for_message(message_id) - - return helpers.to_chat_message(msg=msg, parts=parts) - finally: - conn.close() + return helpers.to_chat_message(mwp) async def get_message_ancestry( self, @@ -465,40 +223,26 @@ async def get_message_ancestry( """Get the ancestry chain of a message. Traverses parent_id chain to build full history. - - Args: - message_id: ID of the message - session_id: Optional session ID hint for faster lookup - - Returns: - List of messages from oldest ancestor to the specified message """ ancestors: list[ChatMessage[str]] = [] if session_id: # Fast path: load all messages for session and traverse in-memory - msg_rows = self._read_message_rows(session_id) - parts_by_msg = self._read_parts_for_session(session_id) - - msg_by_id: dict[str, tuple[MessageInfo, list[Part]]] = {} - for msg_row in msg_rows: - mid: str = msg_row["id"] - msg = self._parse_message(msg_row) - msg_by_id[mid] = (msg, parts_by_msg.get(mid, [])) + session_msgs = self.client.get_session_messages(session_id) + msg_by_id: dict[str, MessageWithParts] = {mwp.info.id: mwp for mwp in session_msgs} current_id: str | None = message_id while current_id: - entry = msg_by_id.get(current_id) - if not entry: + mwp = msg_by_id.get(current_id) + if mwp is None: break - msg, parts = entry - chat_msg = helpers.to_chat_message(msg=msg, parts=parts) + chat_msg = helpers.to_chat_message(mwp) ancestors.append(chat_msg) current_id = chat_msg.parent_id ancestors.reverse() return ancestors - # Slow path: search by message ID + # Slow path: fetch one message at a time current_id = message_id while current_id: ancestor_msg = await self.get_message(current_id) @@ -524,21 +268,7 @@ async def fork_conversation( async def get_session_title(self, session_id: str) -> str | None: """Get the title of a session.""" - try: - conn = self._get_connection() - except FileNotFoundError: - return None - try: - row = conn.execute( - "SELECT title FROM session WHERE id = ?", - (session_id,), - ).fetchone() - if row: - title: str = row["title"] - return title - return None - finally: - conn.close() + return self.client.get_session_title(session_id) if __name__ == "__main__": @@ -549,8 +279,8 @@ async def get_session_title(self, session_id: str) -> str | None: async def main() -> None: provider = OpenCodeStorageProvider() - print(f"Database path: {provider.db_path}") - print(f"Exists: {provider.db_path.exists()}") + print(f"Database path: {provider.client.db_path}") + print(f"Exists: {provider.client.db_path.exists()}") # Get counts conv_count, msg_count = await provider.get_session_counts() diff --git a/src/agentpool_storage/sql_provider/models.py b/src/agentpool_storage/sql_provider/models.py index 0f7d4ff6e..5207a024b 100644 --- a/src/agentpool_storage/sql_provider/models.py +++ b/src/agentpool_storage/sql_provider/models.py @@ -73,7 +73,7 @@ class CommandHistory(AsyncAttrs, SQLModel, table=True): ) """When the command was executed""" - model_config = SQLModelConfig(use_attribute_docstrings=True) # pyright: ignore[reportCallIssue] + model_config = SQLModelConfig(use_attribute_docstrings=True) class MessageLog(Schema): @@ -186,7 +186,7 @@ class Message(AsyncAttrs, SQLModel, table=True): checkpoint_data: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) """A dictionary of checkpoints (name -> metadata).""" - model_config = SQLModelConfig(use_attribute_docstrings=True) # pyright: ignore[reportCallIssue] + model_config = SQLModelConfig(use_attribute_docstrings=True) class Project(AsyncAttrs, SQLModel, table=True): @@ -222,7 +222,7 @@ class Project(AsyncAttrs, SQLModel, table=True): settings_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) """Project-specific settings overrides.""" - model_config = SQLModelConfig(use_attribute_docstrings=True) # pyright: ignore[reportCallIssue] + model_config = SQLModelConfig(use_attribute_docstrings=True) class Conversation(AsyncAttrs, SQLModel, table=True): @@ -285,4 +285,4 @@ class Conversation(AsyncAttrs, SQLModel, table=True): metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) """Protocol-specific or custom metadata stored as JSON.""" - model_config = SQLModelConfig(use_attribute_docstrings=True) # pyright: ignore[reportCallIssue] + model_config = SQLModelConfig(use_attribute_docstrings=True) diff --git a/src/agentpool_storage/sql_provider/sql_provider.py b/src/agentpool_storage/sql_provider/sql_provider.py index 56eb1d876..c3943268f 100644 --- a/src/agentpool_storage/sql_provider/sql_provider.py +++ b/src/agentpool_storage/sql_provider/sql_provider.py @@ -2,7 +2,6 @@ from __future__ import annotations -from decimal import Decimal from typing import TYPE_CHECKING, Any, Self from pydantic_ai import RunUsage @@ -10,9 +9,7 @@ from sqlmodel import SQLModel, desc, select from agentpool.log import get_logger -from agentpool.messaging import TokenCost -from agentpool.utils.parse_time import parse_time_period -from agentpool.utils.time_utils import get_now +from agentpool.utils.time_utils import get_now, parse_time_period from agentpool_config.storage import SQLStorageConfig from agentpool_storage.base import StorageProvider from agentpool_storage.models import QueryFilters @@ -118,7 +115,7 @@ async def log_message(self, *, message: ChatMessage[Any]) -> None: provider, model_name = parse_model_info(message.model_name) cost_info = message.cost_info - + usage = message.usage async with AsyncSession(self.engine) as session: msg = Message( session_id=message.session_id or "", @@ -131,9 +128,9 @@ async def log_message(self, *, message: ChatMessage[Any]) -> None: model_provider=provider, model_name=model_name, response_time=message.response_time, - total_tokens=cost_info.token_usage.total_tokens if cost_info else None, - input_tokens=cost_info.token_usage.input_tokens if cost_info else None, - output_tokens=cost_info.token_usage.output_tokens if cost_info else None, + total_tokens=usage.total_tokens, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, cost=float(cost_info.total_cost) if cost_info else None, provider_name=message.provider_name, provider_response_id=message.provider_response_id, @@ -160,7 +157,7 @@ async def log_session( async with AsyncSession(self.engine) as session: existing = await session.execute( - select(Conversation.id).where(Conversation.id == session_id) # type: ignore[call-overload] + select(Conversation.id).where(Conversation.id == session_id) # type: ignore[call-overload] # ty:ignore[no-matching-overload] ) if existing.scalar_one_or_none() is not None: return @@ -457,7 +454,7 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, Message.total_tokens, Message.input_tokens, Message.output_tokens, - ) + ) # ty:ignore[no-matching-overload] .join(Conversation, Message.session_id == Conversation.id) .where(Message.timestamp > filters.cutoff) ) @@ -472,15 +469,7 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, model, agent, timestamp, - TokenCost( - token_usage=RunUsage( - input_tokens=input_tokens or 0, - output_tokens=output_tokens or 0, - ), - total_cost=Decimal(0), # We don't store this in DB - ) - if total or input_tokens or output_tokens - else None, + RunUsage(input_tokens=input_tokens, output_tokens=output_tokens), ) for model, agent, timestamp, total, input_tokens, output_tokens in result.all() ] @@ -561,7 +550,7 @@ async def delete_session_messages(self, session_id: str) -> int: count = count_result.scalar() or 0 # Then delete await session.execute( - delete(Message).where(Message.session_id == session_id) # type: ignore[arg-type] + delete(Message).where(Message.session_id == session_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] ) await session.commit() return count @@ -602,7 +591,7 @@ async def save_project(self, project: ProjectData) -> None: async with AsyncSession(self.engine) as session: # Delete existing if present (upsert via delete+insert) - stmt = delete(Project).where(Project.project_id == project.project_id) # type: ignore[arg-type] + stmt = delete(Project).where(Project.project_id == project.project_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] await session.execute(stmt) # Insert new/updated db_project = self._to_project_model(project) @@ -648,10 +637,10 @@ async def delete_project(self, project_id: str) -> bool: from sqlalchemy import delete async with AsyncSession(self.engine) as session: - stmt = delete(Project).where(Project.project_id == project_id) # type: ignore[arg-type] + stmt = delete(Project).where(Project.project_id == project_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result = await session.execute(stmt) await session.commit() - deleted: bool = result.rowcount > 0 # type: ignore[attr-defined] + deleted: bool = result.rowcount > 0 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] if deleted: logger.debug("Deleted project", project_id=project_id) return deleted @@ -663,7 +652,7 @@ async def touch_project(self, project_id: str) -> None: async with AsyncSession(self.engine) as session: stmt = ( update(Project) - .where(Project.project_id == project_id) # type: ignore[arg-type] + .where(Project.project_id == project_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] .values(last_active=get_now()) ) await session.execute(stmt) @@ -681,7 +670,7 @@ async def save_session(self, data: SessionData) -> None: async with AsyncSession(self.engine) as session: # Delete existing if present (upsert via delete+insert) - stmt = delete(Conversation).where(Conversation.id == data.session_id) # type: ignore[arg-type] + stmt = delete(Conversation).where(Conversation.id == data.session_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] await session.execute(stmt) # Insert new/updated convo = Conversation( @@ -733,10 +722,10 @@ async def delete_session(self, session_id: str) -> bool: from sqlalchemy import delete async with AsyncSession(self.engine) as session: - stmt = delete(Conversation).where(Conversation.id == session_id) # type: ignore[arg-type] + stmt = delete(Conversation).where(Conversation.id == session_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] result = await session.execute(stmt) await session.commit() - deleted: bool = result.rowcount > 0 # type: ignore[attr-defined] + deleted: bool = result.rowcount > 0 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] if deleted: logger.debug("Deleted session", session_id=session_id) return deleted @@ -753,7 +742,7 @@ async def list_session_ids( stmt = stmt.where(Conversation.pool_id == pool_id) if agent_name is not None: stmt = stmt.where(Conversation.agent_name == agent_name) - stmt = stmt.order_by(Conversation.last_active.desc()) # type: ignore[attr-defined] + stmt = stmt.order_by(Conversation.last_active.desc()) # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] result = await session.execute(stmt) return list(result.scalars().all()) @@ -768,7 +757,7 @@ async def update_sdk_session_id( async with AsyncSession(self.engine) as db: stmt = ( update(Conversation) - .where(Conversation.id == session_id) # type: ignore[arg-type] + .where(Conversation.id == session_id) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] .values(sdk_session_id=sdk_session_id) ) await db.execute(stmt) diff --git a/src/agentpool_storage/sql_provider/utils.py b/src/agentpool_storage/sql_provider/utils.py index c66acccc4..bc1e26c4f 100644 --- a/src/agentpool_storage/sql_provider/utils.py +++ b/src/agentpool_storage/sql_provider/utils.py @@ -22,7 +22,6 @@ from collections.abc import Sequence from sqlmodel.sql.expression import SelectOfScalar - from tokonomics.toko_types import TokenUsage from agentpool_config.session import SessionQuery from agentpool_storage.sql_provider.models import Message @@ -33,35 +32,19 @@ logger = get_logger(__name__) -def aggregate_token_usage( - messages: Sequence[Message | ChatMessage[str]], -) -> TokenUsage: +def aggregate_token_usage(messages: Sequence[ChatMessage[str]]) -> RunUsage: """Sum up tokens from a sequence of messages.""" - from agentpool_storage.sql_provider.models import Message - - total = prompt = completion = 0 + usage = RunUsage() for msg in messages: - if isinstance(msg, Message): - total += msg.total_tokens or 0 - prompt += msg.input_tokens or 0 - completion += msg.output_tokens or 0 - elif msg.cost_info: - total += msg.cost_info.token_usage.total_tokens - prompt += msg.cost_info.token_usage.input_tokens - completion += msg.cost_info.token_usage.output_tokens - return {"total": total, "prompt": prompt, "completion": completion} + usage += msg.usage + return usage def to_chat_message(db_message: Message) -> ChatMessage[str]: """Convert database message to ChatMessage.""" cost_info = None if db_message.total_tokens is not None: - usage = RunUsage( - input_tokens=db_message.input_tokens or 0, - output_tokens=db_message.output_tokens or 0, - ) - cost_info = TokenCost(token_usage=usage, total_cost=Decimal(db_message.cost or 0.0)) - + cost_info = TokenCost(total_cost=Decimal(db_message.cost or 0.0)) return ChatMessage[str]( message_id=db_message.id, session_id=db_message.session_id, @@ -70,6 +53,10 @@ def to_chat_message(db_message: Message) -> ChatMessage[str]: name=db_message.name, model_name=db_message.model, cost_info=cost_info, + usage=RunUsage( + input_tokens=db_message.input_tokens or 0, + output_tokens=db_message.output_tokens or 0, + ), response_time=db_message.response_time, timestamp=db_message.timestamp, provider_name=db_message.provider_name, diff --git a/src/agentpool_storage/zed_provider/helpers.py b/src/agentpool_storage/zed_provider/helpers.py index 09c64ddbb..bad003141 100644 --- a/src/agentpool_storage/zed_provider/helpers.py +++ b/src/agentpool_storage/zed_provider/helpers.py @@ -7,9 +7,9 @@ import base64 import io -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Literal -from pydantic_ai.messages import ( +from pydantic_ai import ( BinaryContent, ModelRequest, ModelResponse, @@ -25,16 +25,32 @@ from agentpool.messaging import ChatMessage from agentpool.mime_utils import detect_image_media_type from agentpool.utils.time_utils import parse_iso_timestamp +from agentpool_storage.zed_provider.models import ( + ZedAgentMessage, + ZedFlatMessage, + ZedImage, + ZedImageContent, + ZedMentionContent, + ZedNestedMessage, + ZedRedactedThinkingBlock, + ZedTextBlock, + ZedTextContent, + ZedThinkingBlock, + ZedToolUseBlock, + ZedUserMessage, +) if TYPE_CHECKING: from datetime import datetime + from pydantic_ai import ModelMessage + from agentpool_storage.zed_provider.models import ( - ZedFlatMessage, - ZedNestedMessage, + ZedAgentContent, ZedThread, ZedToolResult, + ZedUserContent, ) @@ -45,7 +61,7 @@ _ZSTD_DECOMPRESSOR = zstandard.ZstdDecompressor() -def _decompress(data: bytes, data_type: Literal["zstd", "plain"]) -> bytes: +def decompress(data: bytes, data_type: Literal["zstd", "plain"]) -> bytes: """Decompress thread data. Args: @@ -61,7 +77,9 @@ def _decompress(data: bytes, data_type: Literal["zstd", "plain"]) -> bytes: return data -def parse_user_content(items: list[dict[str, Any]]) -> tuple[str, list[str | BinaryContent]]: +def parse_user_content( + items: list[ZedUserContent], +) -> tuple[str, list[str | BinaryContent]]: """Parse user message content blocks. Args: @@ -75,29 +93,16 @@ def parse_user_content(items: list[dict[str, Any]]) -> tuple[str, list[str | Bin for item in items: match item: - case {"Text": text}: + case ZedTextContent(Text=text): display_parts.append(text) pydantic_content.append(text) - - case {"Image": {"source": source}}: + case ZedImageContent(Image=ZedImage(source=source)): binary_data = base64.b64decode(source) media_type = detect_image_media_type(binary_data) pydantic_content.append(BinaryContent(data=binary_data, media_type=media_type)) display_parts.append("[image]") - case {"Mention": {"uri": uri, "content": content}}: - match uri: - case {"File": {"abs_path": path}}: - formatted = f"[File: {path}]\n{content}" - case {"Directory": {"abs_path": path}}: - formatted = f"[Directory: {path}]\n{content}" - case {"Symbol": {"abs_path": path, "name": name}}: - formatted = f"[Symbol: {name} in {path}]\n{content}" - case {"Selection": {"abs_path": path}}: - formatted = f"[Selection: {path}]\n{content}" - case {"Fetch": {"url": url}}: - formatted = f"[Fetched: {url}]\n{content}" - case _: - formatted = content + case ZedMentionContent(Mention=mention): + formatted = mention.formatted() display_parts.append(formatted) pydantic_content.append(formatted) @@ -106,7 +111,7 @@ def parse_user_content(items: list[dict[str, Any]]) -> tuple[str, list[str | Bin def parse_agent_content( - content_list: list[dict[str, Any]], + content_list: list[ZedAgentContent], ) -> tuple[str, list[TextPart | ThinkingPart | ToolCallPart]]: """Parse agent message content blocks. @@ -121,20 +126,23 @@ def parse_agent_content( for item in content_list: match item: - case {"Text": text}: + case ZedTextBlock(Text=text): display_parts.append(text) pydantic_parts.append(TextPart(content=text)) - case {"Thinking": {"text": text, **rest}}: - signature = rest.get("signature") - assert signature is None or isinstance(signature, str) - display_parts.append(f"\n{text}\n") - pydantic_parts.append(ThinkingPart(content=text, signature=signature)) - case {"ToolUse": tool_use}: - tool_id = tool_use.get("id", "") - tool_name = tool_use.get("name", "") - tool_input = tool_use.get("input", {}) - display_parts.append(f"[Tool: {tool_name}]") - part = ToolCallPart(tool_name=tool_name, args=tool_input, tool_call_id=tool_id) + case ZedThinkingBlock(Thinking=thinking): + display_parts.append(f"\n{thinking.text}\n") + pydantic_parts.append( + ThinkingPart(content=thinking.text, signature=thinking.signature) + ) + case ZedRedactedThinkingBlock(RedactedThinking=_data): + display_parts.append("") + case ZedToolUseBlock(ToolUse=tool_use): + display_parts.append(f"[Tool: {tool_use.name}]") + part = ToolCallPart( + tool_name=tool_use.name, + args=tool_use.input, + tool_call_id=tool_use.id, + ) pydantic_parts.append(part) display_text = "\n".join(display_parts) @@ -181,7 +189,7 @@ def _convert_flat_message( """Convert a v0.1.0 flat message to ChatMessage.""" msg_id = f"{thread_id}_{msg.id}" # Extract text from segments - text_parts = [seg.text for seg in msg.segments if seg.text] + text_parts = [seg.text for seg in msg.text_segments] display_text = "\n".join(text_parts) if msg.role == "user": @@ -218,37 +226,35 @@ def _convert_nested_message( ) -> ChatMessage[str]: """Convert a v0.2.0+ nested message to ChatMessage.""" msg_id = f"{thread_id}_{idx}" - - if msg.User is not None: - user_msg = msg.User - display_text, pydantic_content = parse_user_content(user_msg.content) - part = UserPromptPart(content=pydantic_content) - return ChatMessage[str]( - content=display_text, - session_id=thread_id, - role="user", - message_id=user_msg.id or msg_id, - timestamp=updated_at, - messages=[ModelRequest(parts=[part])], - ) - - if msg.Agent is not None: - agent_msg = msg.Agent - display_text, pydantic_parts = parse_agent_content(agent_msg.content) - model_response = ModelResponse(parts=pydantic_parts, model_name=model_name) - pydantic_messages: list[ModelResponse | ModelRequest] = [model_response] - if tool_return_parts := parse_tool_results(agent_msg.tool_results): - pydantic_messages.append(ModelRequest(parts=tool_return_parts)) - return ChatMessage[str]( - content=display_text, - session_id=thread_id, - role="assistant", - message_id=msg_id, - name="zed", - model_name=model_name, - timestamp=updated_at, - messages=pydantic_messages, - ) + match msg: + case ZedNestedMessage(User=ZedUserMessage(content=content, id=user_msg_id)): + display_text, pydantic_content = parse_user_content(content) + part = UserPromptPart(content=pydantic_content) + return ChatMessage[str]( + content=display_text, + session_id=thread_id, + role="user", + message_id=user_msg_id or msg_id, + timestamp=updated_at, + messages=[ModelRequest(parts=[part])], + ) + + case ZedNestedMessage(Agent=ZedAgentMessage(content=content, tool_results=tool_results)): + display_text, pydantic_parts = parse_agent_content(content) + model_response = ModelResponse(parts=pydantic_parts, model_name=model_name) + pydantic_messages: list[ModelMessage] = [model_response] + if tool_return_parts := parse_tool_results(tool_results): + pydantic_messages.append(ModelRequest(parts=tool_return_parts)) + return ChatMessage[str]( + content=display_text, + session_id=thread_id, + role="assistant", + message_id=msg_id, + name="zed", + model_name=model_name, + timestamp=updated_at, + messages=pydantic_messages, + ) raise ValueError("Unexpected message type") @@ -265,8 +271,6 @@ def thread_to_chat_messages(thread: ZedThread, thread_id: str) -> list[ChatMessa Returns: List of ChatMessage objects """ - from agentpool_storage.zed_provider.models import ZedFlatMessage, ZedNestedMessage - messages: list[ChatMessage[str]] = [] updated_at = parse_iso_timestamp(thread.updated_at) model_name = f"{thread.model.provider}:{thread.model.model}" if thread.model else None diff --git a/src/agentpool_storage/zed_provider/models.py b/src/agentpool_storage/zed_provider/models.py index b0884359f..1200a0b13 100644 --- a/src/agentpool_storage/zed_provider/models.py +++ b/src/agentpool_storage/zed_provider/models.py @@ -3,30 +3,130 @@ from __future__ import annotations import io -from typing import Any, Literal +import sys +from typing import Annotated, Any, Literal import anyenv from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic_ai import RunUsage + +from acp.schema.content_blocks import ContentBlock + + +IS_DEV = "pytest" in sys.modules class ZedBaseModel(BaseModel): """Base model with Zed storage.""" - model_config = ConfigDict(use_attribute_docstrings=True, extra="forbid") + model_config = ConfigDict( + use_attribute_docstrings=True, + extra="forbid" if IS_DEV else "ignore", + ) + + +class ZedFileMention(ZedBaseModel): + """File mention.""" + + abs_path: str + + +class ZedDirectoryMention(ZedBaseModel): + """Directory mention.""" + + abs_path: str + + +class ZedLineRange(ZedBaseModel): + """Inclusive line range (0-based, matching Rust's RangeInclusive).""" + + start: int + end: int + + +class ZedSymbolMention(ZedBaseModel): + """Symbol mention with location.""" + + abs_path: str + name: str + line_range: ZedLineRange + + +class ZedSelectionMention(ZedBaseModel): + """Selection mention with optional path and line range.""" + + abs_path: str | None = None + line_range: ZedLineRange + + +class ZedThreadMention(ZedBaseModel): + """Thread mention.""" + + id: str + name: str + + +class ZedTextThreadMention(ZedBaseModel): + """Text thread mention.""" + + path: str + name: str + + +class ZedRuleMention(ZedBaseModel): + """Rule mention.""" + + id: str + name: str + + +class ZedDiagnosticsMention(ZedBaseModel): + """Diagnostics mention.""" + + include_errors: bool = True + include_warnings: bool = False + + +class ZedFetchMention(ZedBaseModel): + """Fetch (URL) mention.""" + + url: str + + +class ZedTerminalSelectionMention(ZedBaseModel): + """Terminal selection mention.""" + + line_count: int = 0 + + +class ZedGitDiffMention(ZedBaseModel): + """Git diff mention.""" + + base_ref: str + + +class ZedMergeConflictMention(ZedBaseModel): + """Merge conflict mention.""" + + file_path: str class ZedMentionUri(ZedBaseModel): - """Mention URI - can be File, Directory, Symbol, etc.""" - - File: dict[str, Any] | None = None - Directory: dict[str, Any] | None = None - Symbol: dict[str, Any] | None = None - Selection: dict[str, Any] | None = None - Thread: dict[str, Any] | None = None - TextThread: dict[str, Any] | None = None - Rule: dict[str, Any] | None = None - Fetch: dict[str, Any] | None = None + """Mention URI - externally tagged enum matching Rust's MentionUri.""" + + File: ZedFileMention | None = None + Directory: ZedDirectoryMention | None = None + Symbol: ZedSymbolMention | None = None + Selection: ZedSelectionMention | None = None + Thread: ZedThreadMention | None = None + TextThread: ZedTextThreadMention | None = None + Rule: ZedRuleMention | None = None + Fetch: ZedFetchMention | None = None PastedImage: bool | None = None + Diagnostics: ZedDiagnosticsMention | None = None + TerminalSelection: ZedTerminalSelectionMention | None = None + GitDiff: ZedGitDiffMention | None = None + MergeConflict: ZedMergeConflictMention | None = None class ZedMention(ZedBaseModel): @@ -35,11 +135,49 @@ class ZedMention(ZedBaseModel): uri: ZedMentionUri content: str + def formatted(self) -> str: # noqa: PLR0911 + """Return a formatted string representation of the mention.""" + if self.uri.File: + return f"[File: {self.uri.File.abs_path}]\n{self.content}" + if self.uri.Directory: + return f"[Directory: {self.uri.Directory.abs_path}]\n{self.content}" + if self.uri.Symbol: + return f"[Symbol: {self.uri.Symbol.name} in {self.uri.Symbol.abs_path}]\n{self.content}" + if self.uri.Selection: + return f"[Selection: {self.uri.Selection.abs_path or ''}]\n{self.content}" + if self.uri.Fetch: + return f"[Fetched: {self.uri.Fetch.url}]\n{self.content}" + if self.uri.Thread: + return f"[Thread: {self.uri.Thread.name}]\n{self.content}" + if self.uri.TextThread: + return f"[TextThread: {self.uri.TextThread.name}]\n{self.content}" + if self.uri.Rule: + return f"[Rule: {self.uri.Rule.name}]\n{self.content}" + if self.uri.Diagnostics: + return f"[Diagnostics]\n{self.content}" + if self.uri.TerminalSelection: + return f"[Terminal Selection]\n{self.content}" + if self.uri.GitDiff: + return f"[Git Diff: {self.uri.GitDiff.base_ref}]\n{self.content}" + if self.uri.MergeConflict: + return f"[Merge Conflict: {self.uri.MergeConflict.file_path}]\n{self.content}" + if self.uri.PastedImage: + return f"[Pasted Image]\n{self.content}" + return self.content + + +class ZedImageSize(ZedBaseModel): + """Image dimensions in device pixels.""" + + width: int + height: int + class ZedImage(ZedBaseModel): """An image in Zed (base64 encoded).""" - source: str # base64 encoded + source: str + size: ZedImageSize | None = None class ZedThinking(ZedBaseModel): @@ -60,14 +198,131 @@ class ZedToolUse(ZedBaseModel): thought_signature: str | None = None +class ZedToolResultContent(ZedBaseModel): + """Tool result content - externally tagged enum (Text or Image).""" + + Text: str | None = None + Image: ZedImage | None = None + + +# Typed tool outputs + + +class ZedEditFileOutput(ZedBaseModel): + """Output from edit_file tool.""" + + input_path: str + old_text: str = "" + new_text: str = "" + diff: str = "" + edit_agent_output: str | None = None + + +class ZedEditFileOutputLegacy(ZedBaseModel): + """Legacy output from edit_file tool (older format).""" + + original_path: str + old_text: str = "" + new_text: str = "" + raw_output: str = "" + + +class ZedFindPathOutput(ZedBaseModel): + """Output from find_path tool.""" + + all_matches_len: int + current_matches_page: list[str] = Field(default_factory=list) + offset: int = 0 + + +class ZedWebSearchResult(ZedBaseModel): + """A single web search result.""" + + title: str = "" + url: str = "" + snippet: str = "" + + +class ZedWebSearchOutput(ZedBaseModel): + """Output from web_search tool.""" + + results: list[ZedWebSearchResult] = Field(default_factory=list) + + +ZedToolOutput = ( + ZedEditFileOutput + | ZedEditFileOutputLegacy + | ZedFindPathOutput + | ZedWebSearchOutput + | ZedToolResultContent + | dict[str, Any] + | str + | None +) + + class ZedToolResult(ZedBaseModel): """Tool result.""" tool_use_id: str tool_name: str is_error: bool = False - content: dict[str, Any] | str | None = None - output: dict[str, Any] | str | None = None + content: ZedToolResultContent | str | None = None + output: ZedToolOutput = None + + +# User message content blocks (v0.2.0+) + + +class ZedTextContent(ZedBaseModel): + """Text content block.""" + + Text: str + + +class ZedImageContent(ZedBaseModel): + """Image content block.""" + + Image: ZedImage + + +class ZedMentionContent(ZedBaseModel): + """Mention content block.""" + + Mention: ZedMention + + +ZedUserContent = ZedTextContent | ZedImageContent | ZedMentionContent + + +# Agent message content blocks (v0.2.0+) + + +class ZedTextBlock(ZedBaseModel): + """Text block in agent message.""" + + Text: str + + +class ZedThinkingBlock(ZedBaseModel): + """Thinking block in agent message.""" + + Thinking: ZedThinking + + +class ZedRedactedThinkingBlock(ZedBaseModel): + """Redacted thinking block in agent message.""" + + RedactedThinking: str + + +class ZedToolUseBlock(ZedBaseModel): + """Tool use block in agent message.""" + + ToolUse: ZedToolUse + + +ZedAgentContent = ZedTextBlock | ZedThinkingBlock | ZedRedactedThinkingBlock | ZedToolUseBlock # v0.2.0+ nested message format @@ -77,13 +332,13 @@ class ZedUserMessage(ZedBaseModel): """User message in Zed thread (v0.2.0+ format).""" id: str - content: list[dict[str, Any]] # Can contain Text, Image, Mention + content: list[ZedUserContent] class ZedAgentMessage(ZedBaseModel): """Agent message in Zed thread (v0.2.0+ format).""" - content: list[dict[str, Any]] # Can contain Text, Thinking, ToolUse + content: list[ZedAgentContent] tool_results: dict[str, ZedToolResult] = Field(default_factory=dict) reasoning_details: Any | None = None @@ -91,19 +346,63 @@ class ZedAgentMessage(ZedBaseModel): class ZedNestedMessage(ZedBaseModel): """A message in Zed thread v0.2.0+ - nested under User or Agent key.""" - User: ZedUserMessage | None = None - Agent: ZedAgentMessage | None = None + User: ZedUserMessage | None = Field(default=None) + Agent: ZedAgentMessage | None = Field(default=None) # Flat message format (v0.1.0, v0.2.0) -class ZedSegment(ZedBaseModel): +class ZedCrease(ZedBaseModel): + """A foldable region in the assistant panel.""" + + start: int + end: int + icon_path: str = "" + label: str = "" + + +class ZedTextSegment(ZedBaseModel): """A segment in a flat message.""" - type: Literal["text", "thinking"] - text: str | None = None - signature: str | None = None # For thinking segments + type: Literal["text"] + text: str + + +class ZedThinkingSegment(ZedBaseModel): + """A segment in a flat message.""" + + type: Literal["thinking"] + signature: str + + +class ZedRedactedThinkingSegment(ZedBaseModel): + """A segment in a flat message.""" + + type: Literal["RedactedThinking"] = "RedactedThinking" + data: str + + +ZedSegment = Annotated[ + ZedTextSegment | ZedThinkingSegment | ZedRedactedThinkingSegment, Field(discriminator="type") +] + + +class ZedFlatToolUse(ZedBaseModel): + """Tool use in flat/legacy message format.""" + + id: str + name: str + input: dict[str, Any] + + +class ZedFlatToolResult(ZedBaseModel): + """Tool result in flat/legacy message format.""" + + tool_use_id: str + is_error: bool = False + content: dict[str, Any] | str | None = None + output: ZedToolOutput = None class ZedFlatMessage(ZedBaseModel): @@ -112,12 +411,16 @@ class ZedFlatMessage(ZedBaseModel): id: int role: Literal["user", "assistant"] segments: list[ZedSegment] = Field(default_factory=list) - tool_uses: list[dict[str, Any]] = Field(default_factory=list) - tool_results: list[dict[str, Any]] = Field(default_factory=list) + tool_uses: list[ZedFlatToolUse] = Field(default_factory=list) + tool_results: list[ZedFlatToolResult] = Field(default_factory=list) context: str = "" - creases: list[Any] = Field(default_factory=list) + creases: list[ZedCrease] = Field(default_factory=list) is_hidden: bool = False + @property + def text_segments(self) -> list[ZedTextSegment]: + return [segment for segment in self.segments if isinstance(segment, ZedTextSegment)] + # Union of all message formats - put ZedFlatMessage first since it's more specific # (has required 'id' and 'role' fields that ZedNestedMessage doesn't have) @@ -139,26 +442,56 @@ class ZedTokenUsage(ZedBaseModel): cache_creation_input_tokens: int = 0 cache_read_input_tokens: int = 0 + def to_run_usage(self) -> RunUsage: + return RunUsage( + input_tokens=self.input_tokens, + output_tokens=self.output_tokens, + cache_write_tokens=self.cache_creation_input_tokens, + cache_read_tokens=self.cache_read_input_tokens, + ) + + +class ZedGitState(ZedBaseModel): + """Git state for a worktree.""" + + remote_url: str | None = None + head_sha: str | None = None + current_branch: str | None = None + diff: str | None = None + class ZedWorktreeSnapshot(ZedBaseModel): """Git worktree snapshot.""" worktree_path: str - git_state: dict[str, Any] | None = None + git_state: ZedGitState | None = None class ZedProjectSnapshot(ZedBaseModel): """Project snapshot with git state.""" worktree_snapshots: list[ZedWorktreeSnapshot] = Field(default_factory=list) - unsaved_buffer_paths: list[str] = Field(default_factory=list) - timestamp: str | None = None + timestamp: str + + +class ZedSubagentContext(ZedBaseModel): + """Context passed to a subagent thread for lifecycle management.""" + + parent_thread_id: str + depth: int + + +class ZedScrollPosition(ZedBaseModel): + """Serialized scroll position in the UI.""" + + item_ix: int + offset_in_item: float class ZedThread(ZedBaseModel): """A Zed conversation thread.""" - model_config = {"populate_by_name": True} + model_config = ConfigDict(populate_by_name=True) # v0.3.0 uses "title", v0.2.0 uses "summary" title: str = Field(alias="title", validation_alias=AliasChoices("title", "summary")) @@ -175,11 +508,15 @@ class ZedThread(ZedBaseModel): default_factory=list ) model: ZedLanguageModel | None = None - completion_mode: str | None = None profile: str | None = None - exceeded_window_error: Any | None = None tool_use_limit_reached: bool = False - imported: bool = False # Whether thread was imported from another source + imported: bool = False + subagent_context: ZedSubagentContext | None = None + speed: Literal["standard", "fast"] | None = None + thinking_enabled: bool = False + thinking_effort: str | None = None + draft_prompt: list[ContentBlock] | None = None + ui_scroll_position: ZedScrollPosition | None = None @classmethod def from_compressed(cls, data: bytes, data_type: Literal["zstd", "plain"]) -> ZedThread: diff --git a/src/agentpool_storage/zed_provider/provider.py b/src/agentpool_storage/zed_provider/provider.py index 1c4e40b8d..f40a893c5 100644 --- a/src/agentpool_storage/zed_provider/provider.py +++ b/src/agentpool_storage/zed_provider/provider.py @@ -9,12 +9,13 @@ from typing import TYPE_CHECKING, Any import anyenv +from pydantic_ai import RunUsage from agentpool.log import get_logger from agentpool.utils.time_utils import get_now, parse_iso_timestamp from agentpool_config.storage import ZedStorageConfig from agentpool_storage.base import StorageProvider -from agentpool_storage.models import ConversationData, TokenUsage +from agentpool_storage.models import ConversationData from agentpool_storage.zed_provider import helpers from agentpool_storage.zed_provider.models import ZedThread @@ -78,7 +79,7 @@ def _list_threads( *, since: datetime | None = None, limit: int | None = None, - ) -> list[tuple[str, str, str]]: + ) -> list[tuple[str, str, str, str | None, str | None, str | None, str | None, str | None]]: """List threads with optional filtering. Args: @@ -86,11 +87,15 @@ def _list_threads( limit: Maximum number of threads to return Returns: - List of (id, summary, updated_at) tuples + List of (id, summary, updated_at, created_at, parent_id, + worktree_branch, folder_paths, folder_paths_order) tuples """ try: conn = self._get_connection() - query = "SELECT id, summary, updated_at FROM threads" + query = ( + "SELECT id, summary, updated_at, created_at, parent_id," + " worktree_branch, folder_paths, folder_paths_order FROM threads" + ) params: list[Any] = [] if since: query += " WHERE updated_at >= ?" @@ -135,8 +140,8 @@ async def filter_messages(self, query: SessionQuery) -> list[ChatMessage[str]]: # Narrow thread list when a specific name is requested threads = self._list_threads() if query.name: - threads = [(tid, s, u) for tid, s, u in threads if query.name in (tid, s)] - for thread_id, _summary, _updated_at in threads: + threads = [t for t in threads if query.name in (t[0], t[1])] + for thread_id, _summary, _updated_at, *_rest in threads: thread = self._load_thread(thread_id) if thread is None: continue @@ -181,7 +186,9 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: """Get filtered conversations with their messages.""" result: list[ConversationData] = [] # Use SQL-level filtering for efficiency - for thread_id, summary, updated_at_str in self._list_threads(since=filters.since): + for thread_id, summary, updated_at_str, created_at_str, *_rest in self._list_threads( + since=filters.since + ): updated_at = parse_iso_timestamp(updated_at_str) thread = self._load_thread(thread_id) if thread is None: @@ -193,24 +200,15 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: continue if filters.query and not any(filters.query in m.content for m in messages): continue - # Get token usage from thread-level cumulative data - usage = thread.cumulative_token_usage - total_tokens = usage.input_tokens + usage.output_tokens - token_usage_data = ( - TokenUsage( - total=total_tokens, prompt=usage.input_tokens, completion=usage.output_tokens - ) - if total_tokens - else None - ) - + # Use created_at for start_time when available, fall back to updated_at + start_time = parse_iso_timestamp(created_at_str) if created_at_str else updated_at conv_data = ConversationData( id=thread_id, agent="zed", title=summary or thread.title, - start_time=updated_at.isoformat(), + start_time=start_time.isoformat(), messages=messages, - token_usage=token_usage_data, + token_usage=thread.cumulative_token_usage.to_run_usage(), ) result.append(conv_data) @@ -222,10 +220,10 @@ async def get_sessions(self, filters: QueryFilters) -> list[ConversationData]: async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, Any]]: """Get session statistics.""" stats: dict[str, dict[str, Any]] = defaultdict( - lambda: {"total_tokens": 0, "messages": 0, "models": set()} + lambda: {"usage": RunUsage(), "messages": 0, "models": set()} ) # Use SQL-level filtering for efficiency - for thread_id, _summary, updated_at_str in self._list_threads(since=filters.cutoff): + for thread_id, _summary, updated_at_str, *_rest in self._list_threads(since=filters.cutoff): timestamp = parse_iso_timestamp(updated_at_str) thread = self._load_thread(thread_id) if thread is None: @@ -242,9 +240,8 @@ async def get_session_stats(self, filters: StatsFilters) -> dict[str, dict[str, case _: key = "zed" # Default agent grouping - usage = thread.cumulative_token_usage stats[key]["messages"] += len(thread.messages) - stats[key]["total_tokens"] += usage.input_tokens + usage.output_tokens + stats[key]["usage"] += thread.cumulative_token_usage.to_run_usage() stats[key]["models"].add(model) # Convert sets to lists for JSON serialization @@ -264,9 +261,8 @@ async def get_session_counts(self, *, agent_name: str | None = None) -> tuple[in msg_count = 0 try: conn = self._get_connection() - cursor = conn.execute("SELECT data_type, data FROM threads") - for data_type, data in cursor: - json_data = helpers._decompress(data, data_type) + for data_type, data in conn.execute("SELECT data_type, data FROM threads"): + json_data = helpers.decompress(data, data_type) thread_dict = anyenv.load_json(json_data, return_type=dict) if (messages := thread_dict.get("messages")) is not None: conv_count += 1 @@ -327,8 +323,8 @@ async def get_message( Note: Zed doesn't store individual message IDs, so this searches threads. """ - threads = [(session_id, None, None)] if session_id else self._list_threads() - for thread_id, _summary, _updated_at in threads: + threads = [(session_id,)] if session_id else self._list_threads() + for thread_id, *_rest in threads: if thread := self._load_thread(thread_id): for msg in helpers.thread_to_chat_messages(thread, thread_id): if msg.message_id == message_id: diff --git a/src/agentpool_toolsets/builtin/code.py b/src/agentpool_toolsets/builtin/code.py index 70f1214e6..0560037a0 100644 --- a/src/agentpool_toolsets/builtin/code.py +++ b/src/agentpool_toolsets/builtin/code.py @@ -371,7 +371,7 @@ async def progress_callback( files = await fs._find(resolved, detail=True) file_paths = [ p - for p, info in files.items() # pyright: ignore[reportAttributeAccessIssue] + for p, info in files.items() if not await is_directory(fs, p, entry_type=info["type"]) ] except Exception as e: # noqa: BLE001 diff --git a/src/agentpool_toolsets/builtin/execution_environment.py b/src/agentpool_toolsets/builtin/execution_environment.py index 52526bab5..3a902b1e1 100644 --- a/src/agentpool_toolsets/builtin/execution_environment.py +++ b/src/agentpool_toolsets/builtin/execution_environment.py @@ -139,7 +139,7 @@ async def get_process_output( # noqa: D417 combined = filter_lines_regex(filter_lines, combined) status = "completed" if output.exit_code is not None else "running" # Format as plain text - suffix_parts = [f"Status: {status}"] + suffix_parts: list[str] = [f"Status: {status}"] if output.exit_code is not None: suffix_parts.append(f"Exit code: {output.exit_code}") if output.truncated: diff --git a/src/agentpool_toolsets/builtin/subagent_tools.py b/src/agentpool_toolsets/builtin/subagent_tools.py index f6e9acceb..b9eaae9af 100644 --- a/src/agentpool_toolsets/builtin/subagent_tools.py +++ b/src/agentpool_toolsets/builtin/subagent_tools.py @@ -7,8 +7,7 @@ import re from typing import TYPE_CHECKING, Any, Literal -from pydantic_ai import ModelRetry -from pydantic_ai.messages import TextPartDelta, ThinkingPartDelta +from pydantic_ai import ModelRetry, TextPartDelta, ThinkingPartDelta from upathtools.filesystems.base import WrapperFileSystem from agentpool.agents.context import AgentContext # noqa: TC001 diff --git a/src/agentpool_toolsets/composio_toolset.py b/src/agentpool_toolsets/composio_toolset.py index df677fa2f..0e108b70a 100644 --- a/src/agentpool_toolsets/composio_toolset.py +++ b/src/agentpool_toolsets/composio_toolset.py @@ -3,7 +3,9 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast + +from schemez import OpenAIFunctionDefinition from agentpool.log import get_logger from agentpool.resource_providers import ResourceProvider @@ -64,13 +66,12 @@ async def get_tools(self) -> Sequence[Tool]: tools = self.composio.tools.get(self.user_id, toolkits=self._toolkits) for tool_def in tools: - # In v3 SDK, tools are OpenAI formatted by default - if isinstance(tool_def, dict) and "function" in tool_def: - tool_slug = tool_def["function"].get("name", "") - if tool_slug: - fn = self._create_tool_handler(tool_slug) - tool = self.create_tool(fn, schema_override=tool_def["function"]) # type: ignore[arg-type] - self._tools.append(tool) + schema = cast(OpenAIFunctionDefinition, tool_def["function"]) + tool_slug = schema.get("name", "") + if tool_slug: + fn = self._create_tool_handler(tool_slug) + tool = self.create_tool(fn, schema_override=schema) + self._tools.append(tool) except Exception: logger.exception("Error getting Composio tools") diff --git a/src/agentpool_toolsets/config_creation.py b/src/agentpool_toolsets/config_creation.py index 3d2467c22..2ac6bc244 100644 --- a/src/agentpool_toolsets/config_creation.py +++ b/src/agentpool_toolsets/config_creation.py @@ -168,7 +168,7 @@ async def read_schema_node(self, path: str) -> str: """ fs = self._get_schema_fs() try: - content = fs.cat(path) + content: str = fs.cat(path) # Parse and re-format for readability schema_data = anyenv.load_json(content) return anyenv.dump_json(schema_data, indent=True) diff --git a/src/agentpool_toolsets/fsspec_toolset/grep.py b/src/agentpool_toolsets/fsspec_toolset/grep.py index a6aef6799..6153dbcda 100644 --- a/src/agentpool_toolsets/fsspec_toolset/grep.py +++ b/src/agentpool_toolsets/fsspec_toolset/grep.py @@ -472,9 +472,8 @@ async def grep_with_fsspec( try: file_content = await fs._cat(file_path) # Skip binary files - if b"\x00" in file_content[:8192]: # pyright: ignore[reportOperatorIssue] + if b"\x00" in file_content[:8192]: continue - text = file_content.decode("utf-8", errors="replace") lines = text.splitlines() diff --git a/src/agentpool_toolsets/fsspec_toolset/toolset.py b/src/agentpool_toolsets/fsspec_toolset/toolset.py index cde0c6c18..37b0acab7 100644 --- a/src/agentpool_toolsets/fsspec_toolset/toolset.py +++ b/src/agentpool_toolsets/fsspec_toolset/toolset.py @@ -16,6 +16,7 @@ from pydantic_ai import ( BinaryContent, ModelResponse, + ModelRetry, PartDeltaEvent, PartStartEvent, RunContext, # noqa: TC002 @@ -322,15 +323,15 @@ async def list_directory( # noqa: D417 suggestion_text = " ".join(suggestions) if suggestions else "" return f"Error: Too many items ({total_found:,}). {suggestion_text}" - for file_path, file_info in paths.items(): # pyright: ignore[reportAttributeAccessIssue] + for file_path, file_info in paths.items(): rel_path = os.path.relpath(str(file_path), path) # Skip excluded patterns if exclude and any(fnmatch(rel_path, pat) for pat in exclude): continue # Use type from glob detail info, falling back to isdir only if needed - is_dir = await is_directory(fs, file_path, entry_type=file_info.get("type")) # pyright: ignore[reportArgumentType] + is_dir = await is_directory(fs, file_path, entry_type=file_info.get("type")) item_info = { - "name": Path(file_path).name, # pyright: ignore[reportArgumentType] + "name": Path(file_path).name, "path": file_path, "relative_path": rel_path, "size": file_info.get("size", 0), @@ -785,6 +786,12 @@ async def regex_replace_lines( ) -> str: r"""Apply regex replacement to a line range specified by line numbers or text markers. + Applies ``re.subn(pattern, replacement, line, count=count)`` to each line + individually within the specified range. Because matching is per-line, + avoid patterns that match the empty string (e.g. ``.*``, ``[\s\S]*``, + ``\d*``) — ``subn`` will match both the content and the trailing empty + string, duplicating the replacement. + Useful for systematic edits: - Remove/add indentation - Comment/uncomment blocks @@ -805,7 +812,7 @@ async def regex_replace_lines( Examples: # Remove a function - regex_replace_lines(ctx, "file.py", "def old_func(", " return", r".*\n", "") + regex_replace_lines(ctx, "file.py", "def old_func(", " return", r".+\n", "") # Indent by line numbers regex_replace_lines(ctx, "file.py", 10, 20, r"^", " ") @@ -847,6 +854,15 @@ async def regex_replace_lines( end_idx = end_line # end_line is inclusive, but list slice is exclusive # Compile regex pattern regex = re.compile(pattern) + # Guard against patterns that match empty strings (e.g. .*, [\s\S]*, + # \d*). These cause subn to replace both the actual content AND the + # empty string after it on every line, duplicating the replacement. + if regex.match("") is not None and replacement: + raise ModelRetry( # noqa: TRY301 + f"Pattern {pattern!r} matches the empty string, which causes " + "duplicate replacements per line. Use a non-optional quantifier " + "(e.g. '.+' instead of '.*') or an anchored pattern (e.g. '^')." + ) # Apply replacements to the specified line range modified_count = 0 replacement_count = 0 @@ -1052,7 +1068,7 @@ async def _read(self, agent_ctx: AgentContext, path: str, encoding: str = "utf-8 # with self.fs.open(path, "r", encoding="utf-8") as f: # return f.read() val = await self._get_fs(agent_ctx)._cat(path) - return val.decode() if isinstance(val, bytes) else val # pyright: ignore[reportReturnType] + return val.decode() if isinstance(val, bytes) else val async def _write(self, agent_ctx: AgentContext, path: str, content: str | bytes) -> None: if isinstance(content, str): diff --git a/src/agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet b/src/agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet index 16995871f..1767e29b0 100644 Binary files a/src/agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet and b/src/agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet differ diff --git a/src/agentpool_toolsets/mcp_discovery/toolset.py b/src/agentpool_toolsets/mcp_discovery/toolset.py index d685e29d1..06ca9d7a5 100644 --- a/src/agentpool_toolsets/mcp_discovery/toolset.py +++ b/src/agentpool_toolsets/mcp_discovery/toolset.py @@ -279,17 +279,17 @@ async def search_mcp_servers( # noqa: D417 # Format results, filtering by allowed/blocked lines = [f"Found MCP servers matching '{query}':\n"] count = 0 - for i in range(len(results)): - name = results["name"][i].as_py() + for row in results.to_pylist(): + name = row["name"] # Filter by allowed/blocked if not self._is_server_allowed(name): continue - desc = results["description"][i].as_py() - version = results["version"][i].as_py() - has_remote = results["has_remote"][i].as_py() - remote_types = results["remote_types"][i].as_py() + desc = row["description"] + version = row["version"] + has_remote = row["has_remote"] + remote_types = row["remote_types"] lines.append(f"**{name}** (v{version})") lines.append(f" {desc}") diff --git a/src/agentpool_toolsets/streaming_tools.py b/src/agentpool_toolsets/streaming_tools.py index c01c21ba9..4df1cefe4 100644 --- a/src/agentpool_toolsets/streaming_tools.py +++ b/src/agentpool_toolsets/streaming_tools.py @@ -50,7 +50,7 @@ async def edit_file( if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable - from pydantic_ai.messages import ModelRequest, ModelResponse + from pydantic_ai import ModelRequest, ModelResponse from agentpool.agents.base_agent import BaseAgent from agentpool.agents.context import AgentContext @@ -166,8 +166,8 @@ async def wrapper(ctx: AgentContext, description: str, **kwargs: Any) -> str: return await fn(ctx, chunk_stream, **kwargs) # type: ignore[no-any-return] # Mark as streaming tool for introspection - wrapper._streaming_tool = True # type: ignore[attr-defined] - wrapper._prompt_template = prompt_template # type: ignore[attr-defined] + wrapper._streaming_tool = True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + wrapper._prompt_template = prompt_template # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return wrapper return decorator diff --git a/src/agentpool_toolsets/vfs_toolset.py b/src/agentpool_toolsets/vfs_toolset.py index 6f1f64975..e3ef5d031 100644 --- a/src/agentpool_toolsets/vfs_toolset.py +++ b/src/agentpool_toolsets/vfs_toolset.py @@ -53,7 +53,7 @@ async def vfs_list( # noqa: D417 # Filter results results: list[str] = [] for item in items: - name = item.get("name", "").strip("/") # pyright: ignore[reportAttributeAccessIssue] + name = item.get("name", "").strip("/") item_type = item.get("type", "file") # Apply exclude patterns @@ -121,7 +121,7 @@ async def vfs_read( # noqa: D417 if isinstance(glob_result, dict): files = list(glob_result.items()) else: - files = [(str(f), {}) for f in glob_result] # pyright: ignore[reportGeneralTypeIssues] + files = [(str(f), {}) for f in glob_result] for file_path, info in sorted(files): # Skip directories diff --git a/src/codex_adapter/README.md b/src/codex_adapter/README.md deleted file mode 100644 index 6967e233a..000000000 --- a/src/codex_adapter/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Codex Adapter - -Python adapter for the [Codex](https://github.com/openai/codex) app-server JSON-RPC protocol. - -## Quick Start - -```python -import asyncio -from codex_adapter import CodexClient -from codex_adapter.models.events import AgentMessageDeltaEvent, TurnCompletedEvent, get_text_delta - -async def main(): - async with CodexClient() as client: - thread = await client.thread_start(cwd="/path/to/project") - - async for event in client.turn_stream(thread.id, "Help me refactor this code"): - match event: - case AgentMessageDeltaEvent(): - print(get_text_delta(event), end="", flush=True) - case TurnCompletedEvent(): - break - -asyncio.run(main()) -``` - -## Structured Responses - -```python -from pydantic import BaseModel - -class FileList(BaseModel): - files: list[str] - total: int - -async with CodexClient() as client: - thread = await client.thread_start(cwd=".") - result = await client.turn_stream_structured( - thread.id, - "List Python files", - FileList, - ) - print(result.files) # Typed result -``` - -## Events - -Events are a discriminated union. Use pattern matching or helper functions: - -```python -from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - CommandExecutionOutputDeltaEvent, - TurnCompletedEvent, - TurnErrorEvent, - get_text_delta, - is_delta_event, -) - -async for event in client.turn_stream(thread_id, message): - match event: - case AgentMessageDeltaEvent() | CommandExecutionOutputDeltaEvent(): - print(get_text_delta(event), end="") - case TurnCompletedEvent(): - break - case TurnErrorEvent(data=data): - print(f"Error: {data.error}") -``` - -## See Also - -- [Codex app-server docs](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) diff --git a/src/codex_adapter/__init__.py b/src/codex_adapter/__init__.py deleted file mode 100644 index 184b3c331..000000000 --- a/src/codex_adapter/__init__.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Codex app-server Python adapter. - -Provides programmatic control over Codex via the app-server JSON-RPC protocol. - -Example: - async with CodexClient() as client: - response = await client.thread_start(cwd="/path/to/project") - async for event in client.turn_stream(response.thread.id, "Help me refactor"): - if event.event_type == "item/agentMessage/delta": - print(event.data.delta, end="", flush=True) -""" - -from codex_adapter.client import CodexClient -from codex_adapter.models.mcp_server import ( - HttpMcpServer, - McpServerConfig, - StdioMcpServer, -) -from codex_adapter.models.codex_types import ( - ApprovalPolicy, - AskForApproval, - CollaborationMode, - CollaborationModeSettings, - DangerFullAccessSandboxPolicy, - ExternalSandboxPolicy, - CollabAgentStatus, - CollabAgentTool, - CollabAgentToolCallStatus, - CommandExecutionApprovalDecision, - CommandExecutionStatus, - DynamicToolCallStatus, - FileChangeApprovalDecision, - InputModality, - ItemStatus, - ItemType, - McpAuthStatusValue, - McpToolCallStatus, - MessagePhase, - ModelProvider, - ModelRerouteReason, - PatchApplyStatus, - Personality, - ReasoningEffort, - ReasoningSummary, - ReviewDelivery, - NetworkAccess, - ReadOnlySandboxPolicy, - RejectApprovalPolicy, - RejectConfig, - SandboxMode, - SandboxPolicy, - ModeKind, - SessionSource, - SkillApprovalDecision, - SkillScope, - ThreadActiveFlag, - WorkspaceWriteSandboxPolicy, - ThreadSortKey, - ThreadSourceKind, - TurnStatus, -) -from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - AppListUpdatedEvent, - CodexEvent, - CommandExecutionOutputDeltaEvent, - ConfigWarningEvent, - EventType, - FileChangeOutputDeltaEvent, - ItemCompletedEvent, - ItemStartedEvent, - ModelReroutedEvent, - PlanDeltaEvent, - ReasoningTextDeltaEvent, - ThreadArchivedEvent, - ThreadCompactedEvent, - ThreadNameUpdatedEvent, - ThreadStartedEvent, - ThreadStatusChangedEvent, - ThreadUnarchivedEvent, - TurnCompletedEvent, - TurnErrorEvent, - TurnPlanUpdatedEvent, - TurnStartedEvent, - get_text_delta, - is_completed_event, - is_delta_event, - is_error_event, - parse_codex_event, -) -from codex_adapter.exceptions import CodexError, CodexProcessError, CodexRequestError -from codex_adapter.models import ( - AgentMessageDeltaData, - AppInfo, - AppListUpdatedData, - CommandExecResponse, - CommandExecutionOutputDeltaData, - ConfigWarningData, - EventData, - ExperimentalFeature, - ImageInputItem, - LocalImageInputItem, - MentionInputItem, - ModelData, - ModelReroutedData, - PlanDeltaData, - ReasoningTextDeltaData, - ReviewStartResponse, - SkillData, - SkillInputItem, - TextInputItem, - ThreadArchivedData, - ThreadData, - ThreadListResponse, - ThreadNameUpdatedData, - ThreadReadResponse, - ThreadResponse, - ThreadRollbackResponse, - ThreadStartedData, - ThreadStatusChangedData, - ThreadUnarchivedData, - ThreadUnarchiveResponse, - TurnCompletedData, - TurnErrorData, - TurnInputItem, - TurnStartedData, - TurnSteerResponse, - ThreadTokenUsage, - TokenUsageBreakdown, - Usage, -) - -__all__ = [ - "AgentMessageDeltaData", - "AgentMessageDeltaEvent", - "AppInfo", - "AppListUpdatedData", - "AppListUpdatedEvent", - "ApprovalPolicy", - "AskForApproval", - "CodexClient", - "CodexError", - "CodexEvent", - "CodexProcessError", - "CodexRequestError", - "CollabAgentStatus", - "CollabAgentTool", - "CollabAgentToolCallStatus", - "CollaborationMode", - "CollaborationModeSettings", - "CommandExecResponse", - "CommandExecutionApprovalDecision", - "CommandExecutionOutputDeltaData", - "CommandExecutionOutputDeltaEvent", - "CommandExecutionStatus", - "ConfigWarningData", - "ConfigWarningEvent", - "DangerFullAccessSandboxPolicy", - "DynamicToolCallStatus", - "EventData", - "EventType", - "ExperimentalFeature", - "ExternalSandboxPolicy", - "FileChangeApprovalDecision", - "FileChangeOutputDeltaEvent", - "HttpMcpServer", - "ImageInputItem", - "InputModality", - "ItemCompletedEvent", - "ItemStartedEvent", - "ItemStatus", - "ItemType", - "LocalImageInputItem", - "McpAuthStatusValue", - "McpServerConfig", - "McpToolCallStatus", - "MentionInputItem", - "MessagePhase", - "ModeKind", - "ModelData", - "ModelProvider", - "ModelRerouteReason", - "ModelReroutedData", - "ModelReroutedEvent", - "NetworkAccess", - "PatchApplyStatus", - "Personality", - "PlanDeltaData", - "PlanDeltaEvent", - "ReadOnlySandboxPolicy", - "ReasoningEffort", - "ReasoningSummary", - "ReasoningTextDeltaData", - "ReasoningTextDeltaEvent", - "RejectApprovalPolicy", - "RejectConfig", - "ReviewDelivery", - "ReviewStartResponse", - "SandboxMode", - "SandboxPolicy", - "SessionSource", - "SkillApprovalDecision", - "SkillData", - "SkillInputItem", - "SkillScope", - "StdioMcpServer", - "TextInputItem", - "ThreadActiveFlag", - "ThreadArchivedData", - "ThreadArchivedEvent", - "ThreadCompactedEvent", - "ThreadData", - "ThreadListResponse", - "ThreadNameUpdatedData", - "ThreadNameUpdatedEvent", - "ThreadReadResponse", - "ThreadResponse", - "ThreadRollbackResponse", - "ThreadSortKey", - "ThreadSourceKind", - "ThreadStartedData", - "ThreadStartedEvent", - "ThreadStatusChangedData", - "ThreadStatusChangedEvent", - "ThreadTokenUsage", - "ThreadUnarchiveResponse", - "ThreadUnarchivedData", - "ThreadUnarchivedEvent", - "TokenUsageBreakdown", - "TurnCompletedData", - "TurnCompletedEvent", - "TurnErrorData", - "TurnErrorEvent", - "TurnInputItem", - "TurnPlanUpdatedEvent", - "TurnStartedData", - "TurnStartedEvent", - "TurnStatus", - "TurnSteerResponse", - "Usage", - "WorkspaceWriteSandboxPolicy", - "get_text_delta", - "is_completed_event", - "is_delta_event", - "is_error_event", - "parse_codex_event", -] diff --git a/src/codex_adapter/client.py b/src/codex_adapter/client.py deleted file mode 100644 index 74509594f..000000000 --- a/src/codex_adapter/client.py +++ /dev/null @@ -1,1631 +0,0 @@ -"""Codex app-server client.""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping # noqa: TC003 -import contextlib -import json -import logging -import os -from typing import TYPE_CHECKING, Any, TypeVar, assert_never - -import anyenv -from pydantic import BaseModel, TypeAdapter - -from codex_adapter.exceptions import CodexProcessError, CodexRequestError -from codex_adapter.models import ( - AgentMessageDeltaData, - AgentMessageDeltaEvent, - AppsListParams, - AppsListResponse, - CancelLoginAccountParams, - CancelLoginAccountResponse, - CollaborationModeListResponse, - CommandExecParams, - CommandExecResponse, - CommandExecutionRequestApprovalParams, - CommandExecutionRequestApprovalResponse, - ConfigBatchWriteParams, - ConfigReadParams, - ConfigReadResponse, - ConfigRequirementsReadResponse, - ConfigValueWriteParams, - ConfigWriteResponse, - DynamicToolCallParams, - DynamicToolCallResponse, - ExperimentalFeatureListParams, - ExperimentalFeatureListResponse, - ExternalAgentConfigDetectParams, - ExternalAgentConfigDetectResponse, - ExternalAgentConfigImportParams, - FeedbackUploadParams, - FeedbackUploadResponse, - FileChangeRequestApprovalParams, - FileChangeRequestApprovalResponse, - GetAccountParams, - GetAccountRateLimitsResponse, - GetAccountResponse, - HttpMcpServer, - InitializeParams, - JsonRpcRequest, - JsonRpcResponse, - ListMcpServerStatusParams, - ListMcpServerStatusResponse, - LoginAccountParams, - LoginAccountResponse, - McpServerOauthLoginParams, - McpServerOauthLoginResponse, - ModelListParams, - ModelListResponse, - ReviewStartParams, - ReviewStartResponse, - SkillsConfigWriteParams, - SkillsListParams, - SkillsListResponse, - SkillsRemoteExportParams, - SkillsRemoteExportResponse, - SkillsRemoteListParams, - SkillsRemoteListResponse, - StdioMcpServer, - TextInputItem, - ThreadArchiveParams, - ThreadCompactStartParams, - ThreadForkParams, - ThreadListParams, - ThreadListResponse, - ThreadLoadedListResponse, - ThreadReadParams, - ThreadReadResponse, - ThreadResponse, - ThreadResumeParams, - ThreadRollbackParams, - ThreadRollbackResponse, - ThreadSetNameParams, - ThreadStartParams, - ThreadUnarchiveParams, - ThreadUnarchiveResponse, - ThreadUnsubscribeParams, - ThreadUnsubscribeResponse, - ToolRequestUserInputParams, - ToolRequestUserInputResponse, - TurnCompletedEvent, - TurnErrorData, - TurnErrorEvent, - TurnInterruptParams, - TurnStartParams, - TurnStartResponse, - TurnSteerParams, - TurnSteerResponse, - parse_codex_event, -) - - -# Server request method constants -SERVER_REQUEST_COMMAND_APPROVAL = "item/commandExecution/requestApproval" -SERVER_REQUEST_FILE_CHANGE_APPROVAL = "item/fileChange/requestApproval" -SERVER_REQUEST_USER_INPUT = "item/tool/requestUserInput" -SERVER_REQUEST_DYNAMIC_TOOL_CALL = "item/tool/call" - -# Type for server request parameter models -ServerRequestParams = ( - CommandExecutionRequestApprovalParams - | FileChangeRequestApprovalParams - | ToolRequestUserInputParams - | DynamicToolCallParams -) - -# Type for server request response models -ServerRequestResponse = ( - CommandExecutionRequestApprovalResponse - | FileChangeRequestApprovalResponse - | ToolRequestUserInputResponse - | DynamicToolCallResponse -) - -# Server request handler callback type -ServerRequestHandler = Callable[[ServerRequestParams], Awaitable[ServerRequestResponse]] - -# Typed handler callbacks for each server request kind -CommandApprovalHandler = Callable[ - [CommandExecutionRequestApprovalParams], - Awaitable[CommandExecutionRequestApprovalResponse], -] -FileChangeApprovalHandler = Callable[ - [FileChangeRequestApprovalParams], - Awaitable[FileChangeRequestApprovalResponse], -] -UserInputHandler = Callable[[ToolRequestUserInputParams], Awaitable[ToolRequestUserInputResponse]] -DynamicToolCallHandler = Callable[[DynamicToolCallParams], Awaitable[DynamicToolCallResponse]] - -# Map from wire method names to param/response model types -_SERVER_REQUEST_TYPES: dict[str, tuple[type[ServerRequestParams], type[ServerRequestResponse]]] = { - SERVER_REQUEST_COMMAND_APPROVAL: ( - CommandExecutionRequestApprovalParams, - CommandExecutionRequestApprovalResponse, - ), - SERVER_REQUEST_FILE_CHANGE_APPROVAL: ( - FileChangeRequestApprovalParams, - FileChangeRequestApprovalResponse, - ), - SERVER_REQUEST_USER_INPUT: (ToolRequestUserInputParams, ToolRequestUserInputResponse), - SERVER_REQUEST_DYNAMIC_TOOL_CALL: (DynamicToolCallParams, DynamicToolCallResponse), -} - - -if TYPE_CHECKING: - from typing import Self - - from codex_adapter.models import ( - AppInfo, - ApprovalPolicy, - CodexEvent, - CollaborationMode, - CollaborationModeMask, - ConfigEdit, - ExperimentalFeature, - ExternalAgentConfigMigrationItem, - McpServerConfig, - MergeStrategy, - ModelData, - Personality, - ReasoningEffort, - ReasoningSummary, - RemoteSkillSummary, - ReviewDelivery, - SandboxMode, - SkillData, - ThreadSortKey, - ThreadSourceKind, - TurnInputItem, - ) - from codex_adapter.models.request_params import HazelnutScope, LoginType, ProductSurface - -ResultType = TypeVar("ResultType", bound=BaseModel) -logger = logging.getLogger(__name__) - - -def _kebab_to_camel(s: str) -> str: - """Convert kebab-case to camelCase.""" - parts = s.split("-") - return parts[0] + "".join(p.capitalize() for p in parts[1:]) - - -def _mcp_config_to_toml_inline(name: str, config: McpServerConfig) -> str: - """Convert MCP server config to TOML inline table format.""" - match config: - case StdioMcpServer(command=command, args=args, env=env, enabled=enabled): - # Build stdio config - parts = [f'command = "{command}"'] - if args: - args_str = ", ".join(f'"{arg}"' for arg in args) - parts.append(f"args = [{args_str}]") - if env: - # env as inline table - env_items = ", ".join(f'{k} = "{v}"' for k, v in env.items()) - parts.append(f"env = {{{env_items}}}") - if not enabled: - parts.append("enabled = false") - return f"mcp_servers.{name}={{{', '.join(parts)}}}" - - case HttpMcpServer( - url=url, - bearer_token_env_var=bearer_token_env_var, - http_headers=http_headers, - enabled=enabled, - ): - # Build HTTP config - parts = [f'url = "{url}"'] - if bearer_token_env_var: - parts.append(f'bearer_token_env_var = "{bearer_token_env_var}"') - if http_headers: - # headers as inline table - headers_items = ", ".join(f'{k} = "{v}"' for k, v in http_headers.items()) - parts.append(f"http_headers = {{{headers_items}}}") - if not enabled: - parts.append("enabled = false") - return f"mcp_servers.{name}={{{', '.join(parts)}}}" - case _: - raise ValueError(f"Unsupported MCP server config type: {type(config)}") - - -class CodexClient: - """Client for the Codex app-server JSON-RPC protocol. - - Manages the subprocess lifecycle and provides async methods for: - - Thread management (conversations) - - Turn management (message exchanges) - - Event streaming via notifications - """ - - def __init__( - self, - codex_command: str = "codex", - profile: str | None = None, - env_vars: dict[str, str] | None = None, - mcp_servers: Mapping[str, McpServerConfig] | None = None, - on_command_approval: CommandApprovalHandler | None = None, - on_file_change_approval: FileChangeApprovalHandler | None = None, - on_user_input: UserInputHandler | None = None, - on_dynamic_tool_call: DynamicToolCallHandler | None = None, - ) -> None: - """Initialize the Codex app-server client. - - Args: - codex_command: Path to the codex binary (default: "codex") - profile: Optional Codex profile to use - env_vars: Optional environment variables to set for the Codex process. - mcp_servers: Optional MCP servers to inject programmatically. - Keys are server names, values are server configurations. - on_command_approval: Handler for command execution approval requests. - on_file_change_approval: Handler for file change approval requests. - on_user_input: Handler for tool user input requests. - on_dynamic_tool_call: Handler for dynamic tool call requests. - """ - self._codex_command = codex_command - self._profile = profile - self._mcp_servers = dict(mcp_servers) if mcp_servers else {} - self._process: asyncio.subprocess.Process | None = None - self._request_id = 0 - self._env_vars = env_vars or {} - self._pending_requests: dict[int, asyncio.Future[Any]] = {} - self._event_queue: asyncio.Queue[CodexEvent | None] = asyncio.Queue() - self._turn_queues: dict[str, asyncio.Queue[CodexEvent | None]] = {} - self._reader_task: asyncio.Task[None] | None = None - self._writer_lock = asyncio.Lock() - self._active_threads: set[str] = set() - self._server_request_handlers: dict[str, ServerRequestHandler] = {} - if on_command_approval: - self.on_server_request(SERVER_REQUEST_COMMAND_APPROVAL, on_command_approval) # type: ignore[arg-type] - if on_file_change_approval: - self.on_server_request(SERVER_REQUEST_FILE_CHANGE_APPROVAL, on_file_change_approval) # type: ignore[arg-type] - if on_user_input: - self.on_server_request(SERVER_REQUEST_USER_INPUT, on_user_input) # type: ignore[arg-type] - if on_dynamic_tool_call: - self.on_server_request(SERVER_REQUEST_DYNAMIC_TOOL_CALL, on_dynamic_tool_call) # type: ignore[arg-type] - - async def __aenter__(self) -> Self: - """Async context manager entry - starts the app-server.""" - await self.start() - return self - - async def __aexit__(self, *_args: object) -> None: - """Async context manager exit - stops the app-server.""" - await self.stop() - - async def start(self) -> None: - """Start the Codex app-server subprocess and initialize connection. - - Raises: - CodexProcessError: If failed to start the process - """ - import agentpool - - if self._process is not None: - return - - cmd = [self._codex_command, "app-server"] - if self._profile: - cmd.extend(["--profile", self._profile]) - # Add MCP server configurations via --config flags - for server_name, server_config in self._mcp_servers.items(): - config_str = _mcp_config_to_toml_inline(server_name, server_config) - cmd.extend(["--config", config_str]) - - logger.info("Starting Codex app-server: %s", " ".join(cmd)) - try: - self._process = await anyenv.create_process( - *cmd, - stdin="pipe", - stdout="pipe", - stderr="pipe", - env={**os.environ, **self._env_vars}, - ) - except FileNotFoundError as exc: - raise CodexProcessError(f"Codex binary not found: {self._codex_command}") from exc - except Exception as exc: - raise CodexProcessError(f"Failed to start Codex app-server: {exc}") from exc - # Start reader task - self._reader_task = asyncio.create_task(self._read_loop()) - # Initialize connection - version = agentpool.__version__ - init_params = InitializeParams.create(name="agentpool-codex-adapter", version=version) - await self._send_request("initialize", init_params) - - async def stop(self) -> None: - """Stop the Codex app-server subprocess.""" - if self._process is None: - return - - # Cancel reader task - if self._reader_task: - self._reader_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._reader_task - - # Terminate process - if self._process.returncode is None: - self._process.terminate() - try: - await asyncio.wait_for(self._process.wait(), timeout=5.0) - except TimeoutError: - self._process.kill() - await self._process.wait() - - self._process = None - # Reject pending requests - for future in self._pending_requests.values(): - if not future.done(): - future.set_exception(CodexProcessError("Connection closed")) - self._pending_requests.clear() - - # ======================================================================== - # Thread lifecycle methods - # ======================================================================== - - async def thread_start( - self, - *, - cwd: str | None = None, - model: str | None = None, - model_provider: str | None = None, - base_instructions: str | None = None, - developer_instructions: str | None = None, - approval_policy: ApprovalPolicy | None = None, - sandbox: SandboxMode | None = None, - config: dict[str, Any] | None = None, - service_name: str | None = None, - personality: Personality | None = None, - ephemeral: bool | None = None, - ) -> ThreadResponse: - """Start a new conversation thread. - - Args: - cwd: Working directory for the thread - model: Model to use (e.g., "gpt-5-codex") - model_provider: Model provider (e.g., "openai", "anthropic") - base_instructions: Base system instructions for the thread - developer_instructions: Developer-provided instructions - approval_policy: Tool approval policy - sandbox: Sandbox mode for file operations - config: Additional configuration overrides - service_name: Optional service name - personality: Personality preset (none, friendly, pragmatic) - ephemeral: If true, thread is not persisted to disk - - Returns: - ThreadResponse containing thread data and configuration - """ - params = ThreadStartParams( - cwd=cwd, - model=model, - model_provider=model_provider, - base_instructions=base_instructions, - developer_instructions=developer_instructions, - approval_policy=approval_policy, - sandbox=sandbox, - config=config, - service_name=service_name, - personality=personality, - ephemeral=ephemeral, - ) - result = await self._send_request("thread/start", params) - response = ThreadResponse.model_validate(result) - self._active_threads.add(response.thread.id) - return response - - async def thread_resume( - self, - thread_id: str, - *, - path: str | None = None, - cwd: str | None = None, - model: str | None = None, - model_provider: str | None = None, - base_instructions: str | None = None, - developer_instructions: str | None = None, - approval_policy: ApprovalPolicy | None = None, - sandbox: SandboxMode | None = None, - config: dict[str, Any] | None = None, - personality: Personality | None = None, - ) -> ThreadResponse: - """Resume an existing thread by ID. - - Args: - thread_id: ID of the thread to resume - path: Path to thread storage - cwd: Working directory override - model: Model override - model_provider: Model provider override - base_instructions: Base system instructions override - developer_instructions: Developer instructions override - approval_policy: Tool approval policy override - sandbox: Sandbox mode override - config: Additional configuration overrides - personality: Personality override - - Returns: - ThreadResponse containing thread data with conversation history - """ - params = ThreadResumeParams( - thread_id=thread_id, - path=path, - cwd=cwd, - model=model, - model_provider=model_provider, - base_instructions=base_instructions, - developer_instructions=developer_instructions, - approval_policy=approval_policy, - sandbox=sandbox, - config=config, - personality=personality, - ) - result = await self._send_request("thread/resume", params) - response = ThreadResponse.model_validate(result) - self._active_threads.add(response.thread.id) - return response - - async def thread_fork( - self, - thread_id: str, - *, - path: str | None = None, - cwd: str | None = None, - model: str | None = None, - model_provider: str | None = None, - base_instructions: str | None = None, - developer_instructions: str | None = None, - approval_policy: ApprovalPolicy | None = None, - sandbox: SandboxMode | None = None, - config: dict[str, Any] | None = None, - personality: Personality | None = None, - ) -> ThreadResponse: - """Fork an existing thread into a new thread with copied history. - - Args: - thread_id: ID of the thread to fork - path: Path to thread storage - cwd: Working directory for the forked thread - model: Model override for forked thread - model_provider: Model provider override - base_instructions: Base system instructions for forked thread - developer_instructions: Developer instructions for forked thread - approval_policy: Tool approval policy for forked thread - sandbox: Sandbox mode for forked thread - config: Additional configuration overrides - personality: Personality for forked thread - - Returns: - ThreadResponse containing the new forked thread data - """ - params = ThreadForkParams( - thread_id=thread_id, - path=path, - cwd=cwd, - model=model, - model_provider=model_provider, - base_instructions=base_instructions, - developer_instructions=developer_instructions, - approval_policy=approval_policy, - sandbox=sandbox, - config=config, - personality=personality, - ) - result = await self._send_request("thread/fork", params) - response = ThreadResponse.model_validate(result) - self._active_threads.add(response.thread.id) - return response - - async def thread_list( - self, - *, - cursor: str | None = None, - limit: int | None = None, - sort_key: ThreadSortKey | None = None, - model_providers: list[str] | None = None, - source_kinds: list[ThreadSourceKind] | None = None, - archived: bool | None = None, - cwd: str | None = None, - search_term: str | None = None, - ) -> ThreadListResponse: - """List stored threads with pagination. - - Args: - cursor: Opaque pagination cursor from previous response - limit: Maximum number of threads to return - sort_key: Sort key (created_at or updated_at) - model_providers: Filter by model providers - source_kinds: Filter by source kinds - archived: If true, only return archived threads - cwd: Filter by working directory - search_term: Substring filter for thread title - - Returns: - ThreadListResponse with data (list of threads) and next_cursor - """ - params = ThreadListParams( - cursor=cursor, - limit=limit, - sort_key=sort_key, - model_providers=model_providers, - source_kinds=source_kinds, - archived=archived, - cwd=cwd, - search_term=search_term, - ) - result = await self._send_request("thread/list", params) - return ThreadListResponse.model_validate(result) - - async def thread_read( - self, thread_id: str, *, include_turns: bool = False - ) -> ThreadReadResponse: - """Read a thread's data.""" - params = ThreadReadParams(thread_id=thread_id, include_turns=include_turns) - result = await self._send_request("thread/read", params) - return ThreadReadResponse.model_validate(result) - - async def thread_loaded_list(self) -> list[str]: - """List thread IDs currently loaded in memory.""" - result = await self._send_request("thread/loaded/list") - response = ThreadLoadedListResponse.model_validate(result) - return response.data - - async def thread_unsubscribe(self, thread_id: str) -> ThreadUnsubscribeResponse: - """Stop listening to a thread's events. - - Args: - thread_id: The thread ID to unsubscribe from - - Returns: - ThreadUnsubscribeResponse with status (notLoaded/notSubscribed/unsubscribed) - """ - params = ThreadUnsubscribeParams(thread_id=thread_id) - result = await self._send_request("thread/unsubscribe", params) - return ThreadUnsubscribeResponse.model_validate(result) - - async def thread_archive(self, thread_id: str) -> None: - """Archive a thread (move to archived directory).""" - params = ThreadArchiveParams(thread_id=thread_id) - await self._send_request("thread/archive", params) - self._active_threads.discard(thread_id) - - async def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: - """Unarchive a previously archived thread. Returns unarchived thread data.""" - params = ThreadUnarchiveParams(thread_id=thread_id) - result = await self._send_request("thread/unarchive", params) - return ThreadUnarchiveResponse.model_validate(result) - - async def thread_set_name(self, thread_id: str, name: str) -> None: - """Set a user-facing name for a thread.""" - params = ThreadSetNameParams(thread_id=thread_id, name=name) - await self._send_request("thread/name/set", params) - - async def thread_compact_start(self, thread_id: str) -> None: - """Trigger context compaction for a thread.""" - params = ThreadCompactStartParams(thread_id=thread_id) - await self._send_request("thread/compact/start", params) - - async def thread_rollback(self, thread_id: str, turns: int) -> ThreadRollbackResponse: - """Rollback the last N turns from a thread. - - Args: - thread_id: The thread ID - turns: Number of turns to rollback - - Returns: - Updated thread object with turns populated - """ - params = ThreadRollbackParams(thread_id=thread_id, turns=turns) - result = await self._send_request("thread/rollback", params) - return ThreadRollbackResponse.model_validate(result) - - # ======================================================================== - # Turn methods - # ======================================================================== - - async def turn_stream( - self, - thread_id: str, - user_input: str | list[TurnInputItem], - *, - model: str | None = None, - effort: ReasoningEffort | None = None, - approval_policy: ApprovalPolicy | None = None, - cwd: str | None = None, - sandbox_policy: SandboxMode | dict[str, Any] | None = None, - output_schema: dict[str, Any] | type[Any] | None = None, - personality: Personality | None = None, - summary: ReasoningSummary | None = None, - collaboration_mode: CollaborationMode | None = None, - ) -> AsyncIterator[CodexEvent]: - """Start a turn and stream events. - - Args: - thread_id: The thread ID to send the turn to - user_input: User input as string or list of input items (text/image) - model: Optional model override for this turn - effort: Optional reasoning effort override - approval_policy: Optional approval policy - cwd: Optional working directory override for this and subsequent turns - sandbox_policy: Optional sandbox mode or policy dict - output_schema: Optional JSON Schema dict or Pydantic type to constrain output - personality: Optional personality override - summary: Optional reasoning summary mode - collaboration_mode: Optional collaboration mode preset (experimental) - - Yields: - CodexEvent: Streaming events from the turn - """ - # Convert user_input to typed input format - input_items: list[TurnInputItem] = ( - [TextInputItem(text=user_input)] if isinstance(user_input, str) else user_input - ) - # Handle output_schema - convert type to JSON Schema if needed - match output_schema: - case None: - schema_dict: dict[str, Any] | None = None - case dict(): - schema_dict = output_schema - case type(): # It's a type - use TypeAdapter to extract schema - schema_dict = TypeAdapter(output_schema).json_schema() - case _ as unreachable: - assert_never(unreachable) - # Handle sandbox_policy - convert string to dict if needed - # Turn-level API uses camelCase (workspaceWrite), thread-level uses kebab-case - match sandbox_policy: - case None: - sandbox_dict: dict[str, Any] | None = None - case str(): - # Convert kebab-case to camelCase for turn API - sandbox_dict = {"type": _kebab_to_camel(sandbox_policy)} - case dict(): - sandbox_dict = sandbox_policy - case _: - assert_never(sandbox_policy) - # Build typed params - params = TurnStartParams( - thread_id=thread_id, - input=input_items, - model=model, - effort=effort, - approval_policy=approval_policy, - cwd=cwd, - sandbox_policy=sandbox_dict, - output_schema=schema_dict, - personality=personality, - summary=summary, - collaboration_mode=collaboration_mode, - ) - - # Start turn (non-blocking request) - turn_result = await self._send_request("turn/start", params) - response = TurnStartResponse.model_validate(turn_result) - turn_id = response.turn.id - - # Create per-turn event queue for proper routing - turn_queue: asyncio.Queue[CodexEvent | None] = asyncio.Queue() - turn_key = f"{thread_id}:{turn_id}" - self._turn_queues[turn_key] = turn_queue - - try: - # Stream events until turn completes - while True: - event = await turn_queue.get() - match event: - case None: - break - case TurnCompletedEvent(): - yield event - break - case TurnErrorEvent(data=TurnErrorData(error=error)): - yield event - raise CodexRequestError(-32000, error) - finally: - # Cleanup turn queue - if turn_key in self._turn_queues: - del self._turn_queues[turn_key] - - async def turn_steer( - self, - thread_id: str, - user_input: str | list[TurnInputItem], - *, - expected_turn_id: str, - ) -> TurnSteerResponse: - """Steer a running turn with additional input. - - Args: - thread_id: The thread ID - user_input: Additional user input - expected_turn_id: The expected active turn ID (precondition) - - Returns: - TurnSteerResponse with the turn ID - """ - input_items: list[TurnInputItem] = ( - [TextInputItem(text=user_input)] if isinstance(user_input, str) else user_input - ) - params = TurnSteerParams( - thread_id=thread_id, - input=input_items, - expected_turn_id=expected_turn_id, - ) - result = await self._send_request("turn/steer", params) - return TurnSteerResponse.model_validate(result) - - async def turn_interrupt(self, thread_id: str, turn_id: str) -> None: - """Interrupt a running turn. - - Args: - thread_id: The thread ID - turn_id: The turn ID to interrupt - """ - params = TurnInterruptParams(thread_id=thread_id, turn_id=turn_id) - await self._send_request("turn/interrupt", params) - - async def turn_stream_structured( - self, - thread_id: str, - user_input: str | list[TurnInputItem], - result_type: type[ResultType], - *, - model: str | None = None, - effort: ReasoningEffort | None = None, - approval_policy: ApprovalPolicy | None = None, - cwd: str | None = None, - sandbox_policy: SandboxMode | dict[str, Any] | None = None, - personality: Personality | None = None, - summary: ReasoningSummary | None = None, - collaboration_mode: CollaborationMode | None = None, - ) -> ResultType: - """Start a turn with structured output and return the parsed result. - - This is a convenience method that combines turn_stream with automatic - schema generation and result parsing. Similar to PydanticAI's approach. - - Note: This method only accepts Pydantic types (not raw dict schemas). - For dict schemas, use turn_stream() with output_schema and parse manually. - - Args: - thread_id: The thread ID to send the turn to - user_input: User input as string or list of items - result_type: Pydantic model class for the expected result (not a dict!) - model: Optional model override for this turn - effort: Optional reasoning effort override - approval_policy: Optional approval policy - cwd: Optional working directory override for this and subsequent turns - sandbox_policy: Optional sandbox mode or policy dict - personality: Optional personality override - summary: Optional reasoning summary mode - collaboration_mode: Optional collaboration mode preset (experimental) - - Returns: - Parsed Pydantic model instance of type result_type - - Raises: - ValidationError: If the agent's response doesn't match the schema - CodexRequestError: If the turn fails - - Example: - class FileInfo(BaseModel): - name: str - type: str - - class FileList(BaseModel): - files: list[FileInfo] - total: int - - result = await client.turn_stream_structured( - thread.id, - "List Python files in current directory", - FileList, # Must be a Pydantic type, not a dict - ) - print(f"Found {result.total} files: {result.files}") - """ - # Collect agent message text - response_text = "" - async for event in self.turn_stream( - thread_id, - user_input, - model=model, - effort=effort, - approval_policy=approval_policy, - cwd=cwd, - sandbox_policy=sandbox_policy, - output_schema=result_type, # Auto-generate schema from type - personality=personality, - summary=summary, - collaboration_mode=collaboration_mode, - ): - match event: - case AgentMessageDeltaEvent(data=AgentMessageDeltaData(delta=delta)): - response_text += delta - case TurnErrorEvent(data=TurnErrorData(error=error)): - raise CodexRequestError(-32000, error) - - # Parse into typed model - return result_type.model_validate_json(response_text) - - # ======================================================================== - # Review methods - # ======================================================================== - - async def review_start( - self, - thread_id: str, - target: dict[str, Any], - *, - delivery: ReviewDelivery | None = None, - ) -> ReviewStartResponse: - """Start a code review. - - Args: - thread_id: The thread ID to start the review on - target: Review target (uncommittedChanges, baseBranch, commit, or custom) - delivery: Where to run the review (inline or detached) - - Returns: - ReviewStartResponse with turn and review thread ID - """ - params = ReviewStartParams( - thread_id=thread_id, - target=target, - delivery=delivery, - ) - result = await self._send_request("review/start", params) - return ReviewStartResponse.model_validate(result) - - # ======================================================================== - # Skills methods - # ======================================================================== - - async def skills_list( - self, - *, - cwds: list[str] | None = None, - force_reload: bool | None = None, - ) -> list[SkillData]: - """List available skills. - - Args: - cwds: Optional working directories to scope skills - force_reload: Force reload of skills cache - - Returns: - List of skills with metadata - """ - params = SkillsListParams(cwds=cwds, force_reload=force_reload) - result = await self._send_request("skills/list", params) - response = SkillsListResponse.model_validate(result) - # Return skills from first container (usually only one) - if response.data: - return response.data[0].skills - return [] - - async def skills_config_write(self, path: str, *, enabled: bool) -> None: - """Write skills configuration. - - Args: - path: Path to the skill - enabled: Whether the skill is enabled - """ - params = SkillsConfigWriteParams(path=path, enabled=enabled) - await self._send_request("skills/config/write", params) - - async def skills_remote_list( - self, - *, - hazelnut_scope: HazelnutScope = "example", - product_surface: ProductSurface = "codex", - enabled: bool = False, - ) -> list[RemoteSkillSummary]: - """List remote skills. - - Args: - hazelnut_scope: Scope filter (example/workspace-shared/all-shared/personal) - product_surface: Product surface filter (chatgpt/codex/api/atlas) - enabled: Whether to filter by enabled status - - Returns: - List of remote skill summaries - """ - params = SkillsRemoteListParams( - hazelnut_scope=hazelnut_scope, - product_surface=product_surface, - enabled=enabled, - ) - result = await self._send_request("skills/remote/list", params) - response = SkillsRemoteListResponse.model_validate(result) - return response.data - - async def skills_remote_export(self, hazelnut_id: str) -> SkillsRemoteExportResponse: - """Export a skill to remote storage. - - Args: - hazelnut_id: ID of the remote skill to export - - Returns: - SkillsRemoteExportResponse with id and local path - """ - params = SkillsRemoteExportParams(hazelnut_id=hazelnut_id) - result = await self._send_request("skills/remote/export", params) - return SkillsRemoteExportResponse.model_validate(result) - - # ======================================================================== - # Model methods - # ======================================================================== - - async def model_list( - self, - *, - include_hidden: bool | None = None, - ) -> list[ModelData]: - """List available models with reasoning effort options. - - Args: - include_hidden: When true, include hidden models - - Returns: - List of available models - """ - params = ModelListParams(include_hidden=include_hidden) - result = await self._send_request("model/list", params) - response = ModelListResponse.model_validate(result) - return response.data - - async def collaboration_mode_list(self) -> list[CollaborationModeMask]: - """List available collaboration mode presets (experimental). - - Returns: - List of collaboration mode presets with name, mode, model, and effort - """ - result = await self._send_request("collaborationMode/list") - response = CollaborationModeListResponse.model_validate(result) - return response.data - - # ======================================================================== - # Command execution - # ======================================================================== - - async def command_exec( - self, - command: list[str], - *, - cwd: str | None = None, - sandbox_policy: dict[str, Any] | None = None, - timeout_ms: int | None = None, - ) -> CommandExecResponse: - """Execute a command without creating a thread/turn. - - Args: - command: Command and arguments as list (e.g., ["ls", "-la"]) - cwd: Working directory for command - sandbox_policy: Sandbox policy override - timeout_ms: Timeout in milliseconds - - Returns: - CommandExecResponse with exit_code, stdout, stderr - """ - params = CommandExecParams( - command=command, - cwd=cwd, - sandbox_policy=sandbox_policy, - timeout_ms=timeout_ms, - ) - result = await self._send_request("command/exec", params) - return CommandExecResponse.model_validate(result) - - # ======================================================================== - # MCP server methods - # ======================================================================== - - async def mcp_server_refresh(self) -> None: - """Reload MCP server configurations from disk. - - Triggers all threads to rebuild their MCP connections on the next turn - using the latest config file. - """ - await self._send_request("config/mcpServer/reload") - - async def mcp_server_status_list( - self, - *, - cursor: str | None = None, - limit: int | None = None, - ) -> ListMcpServerStatusResponse: - """List MCP server status with tool and resource information. - - Args: - cursor: Pagination cursor from previous call - limit: Maximum number of servers to return - - Returns: - Response with server status entries and optional next_cursor - """ - params = ListMcpServerStatusParams(cursor=cursor, limit=limit) - result = await self._send_request("mcpServerStatus/list", params) - return ListMcpServerStatusResponse.model_validate(result) - - async def mcp_server_oauth_login( - self, - name: str, - *, - scopes: list[str] | None = None, - timeout_secs: int | None = None, - ) -> McpServerOauthLoginResponse: - """Start OAuth login for an MCP server. - - Args: - name: Name of the MCP server - scopes: Optional OAuth scopes to request - timeout_secs: Optional timeout in seconds - - Returns: - Response with authorization URL - """ - params = McpServerOauthLoginParams( - name=name, - scopes=scopes, - timeout_secs=timeout_secs, - ) - result = await self._send_request("mcpServer/oauth/login", params) - return McpServerOauthLoginResponse.model_validate(result) - - # ======================================================================== - # Account methods - # ======================================================================== - - async def account_read(self, *, refresh_token: bool = False) -> GetAccountResponse: - """Read account information. - - Args: - refresh_token: When true, trigger a proactive token refresh - - Returns: - GetAccountResponse with account info - """ - params = GetAccountParams(refresh_token=refresh_token) - result = await self._send_request("account/read", params) - return GetAccountResponse.model_validate(result) - - async def account_login_start( - self, - login_type: LoginType, - *, - api_key: str | None = None, - access_token: str | None = None, - chatgpt_account_id: str | None = None, - ) -> LoginAccountResponse: - """Start account login. - - Args: - login_type: Login type (apiKey, chatgpt, chatgptAuthTokens) - api_key: API key (for apiKey type) - access_token: Access token (for chatgptAuthTokens type) - chatgpt_account_id: ChatGPT account ID (for chatgptAuthTokens type) - - Returns: - LoginAccountResponse with login details - """ - params = LoginAccountParams( - type=login_type, - api_key=api_key, - access_token=access_token, - chatgpt_account_id=chatgpt_account_id, - ) - result = await self._send_request("account/login/start", params) - return LoginAccountResponse.model_validate(result) - - async def account_login_cancel(self, login_id: str) -> CancelLoginAccountResponse: - """Cancel an in-progress account login. - - Args: - login_id: The login ID to cancel - - Returns: - CancelLoginAccountResponse with status - """ - params = CancelLoginAccountParams(login_id=login_id) - result = await self._send_request("account/login/cancel", params) - return CancelLoginAccountResponse.model_validate(result) - - async def account_logout(self) -> None: - """Logout from the current account.""" - await self._send_request("account/logout") - - async def account_rate_limits_read(self) -> GetAccountRateLimitsResponse: - """Read account rate limits. - - Returns: - GetAccountRateLimitsResponse with rate limit information - """ - result = await self._send_request("account/rateLimits/read") - return GetAccountRateLimitsResponse.model_validate(result) - - # ======================================================================== - # Config methods - # ======================================================================== - - async def config_read( - self, - *, - include_layers: bool = False, - cwd: str | None = None, - ) -> ConfigReadResponse: - """Read configuration. - - Args: - include_layers: Whether to include config layer details - cwd: Optional working directory for project config resolution - - Returns: - ConfigReadResponse with config data - """ - params = ConfigReadParams(include_layers=include_layers, cwd=cwd) - result = await self._send_request("config/read", params) - return ConfigReadResponse.model_validate(result) - - async def config_value_write( - self, - key_path: str, - value: Any, - merge_strategy: MergeStrategy, - *, - file_path: str | None = None, - expected_version: str | None = None, - ) -> ConfigWriteResponse: - """Write a config value. - - Args: - key_path: Dotted key path (e.g., "model") - value: Value to write - merge_strategy: How to merge (replace or merge) - file_path: Optional config file path - expected_version: Optional expected version for optimistic locking - - Returns: - ConfigWriteResponse with status - """ - params = ConfigValueWriteParams( - key_path=key_path, - value=value, - merge_strategy=merge_strategy, - file_path=file_path, - expected_version=expected_version, - ) - result = await self._send_request("config/value/write", params) - return ConfigWriteResponse.model_validate(result) - - async def config_batch_write( - self, - edits: list[ConfigEdit], - *, - file_path: str | None = None, - expected_version: str | None = None, - ) -> ConfigWriteResponse: - """Batch write config values. - - Args: - edits: List of ConfigEdit objects - file_path: Optional config file path - expected_version: Optional expected version for optimistic locking - - Returns: - ConfigWriteResponse with status - """ - params = ConfigBatchWriteParams( - edits=edits, - file_path=file_path, - expected_version=expected_version, - ) - result = await self._send_request("config/batchWrite", params) - return ConfigWriteResponse.model_validate(result) - - async def config_requirements_read(self) -> ConfigRequirementsReadResponse: - """Read config requirements. - - Returns: - ConfigRequirementsReadResponse with requirements - """ - result = await self._send_request("configRequirements/read") - return ConfigRequirementsReadResponse.model_validate(result) - - # ======================================================================== - # Apps methods - # ======================================================================== - - async def apps_list( - self, - *, - cursor: str | None = None, - limit: int | None = None, - thread_id: str | None = None, - force_refetch: bool | None = None, - ) -> list[AppInfo]: - """List available apps/connectors. - - Args: - cursor: Pagination cursor - limit: Maximum number of apps to return - thread_id: Optional thread ID for feature gating - force_refetch: Bypass caches and fetch latest - - Returns: - List of AppInfo objects - """ - params = AppsListParams( - cursor=cursor, - limit=limit, - thread_id=thread_id, - force_refetch=force_refetch, - ) - result = await self._send_request("app/list", params) - response = AppsListResponse.model_validate(result) - return response.data - - # ======================================================================== - # Experimental feature methods - # ======================================================================== - - async def experimental_feature_list( - self, - *, - cursor: str | None = None, - limit: int | None = None, - ) -> list[ExperimentalFeature]: - """List experimental features. - - Args: - cursor: Pagination cursor - limit: Maximum number of features to return - - Returns: - List of ExperimentalFeature objects - """ - params = ExperimentalFeatureListParams(cursor=cursor, limit=limit) - result = await self._send_request("experimentalFeature/list", params) - response = ExperimentalFeatureListResponse.model_validate(result) - return response.data - - # ======================================================================== - # Feedback methods - # ======================================================================== - - async def feedback_upload( - self, - classification: str, - *, - reason: str | None = None, - thread_id: str | None = None, - include_logs: bool = False, - extra_log_files: list[str] | None = None, - ) -> FeedbackUploadResponse: - """Upload feedback. - - Args: - classification: Feedback classification - reason: Optional reason text - thread_id: Optional thread ID to associate - include_logs: Whether to include logs - extra_log_files: Additional log files to include - - Returns: - FeedbackUploadResponse with thread ID - """ - params = FeedbackUploadParams( - classification=classification, - reason=reason, - thread_id=thread_id, - include_logs=include_logs, - extra_log_files=extra_log_files, - ) - result = await self._send_request("feedback/upload", params) - return FeedbackUploadResponse.model_validate(result) - - # ======================================================================== - # External agent config methods - # ======================================================================== - - async def external_agent_config_detect( - self, - *, - include_home: bool | None = None, - cwds: list[str] | None = None, - ) -> ExternalAgentConfigDetectResponse: - """Detect external agent configurations. - - Args: - include_home: Include detection under user's home directory - cwds: Working directories for repo-scoped detection - - Returns: - ExternalAgentConfigDetectResponse with migration items - """ - params = ExternalAgentConfigDetectParams(include_home=include_home, cwds=cwds) - result = await self._send_request("externalAgentConfig/detect", params) - return ExternalAgentConfigDetectResponse.model_validate(result) - - async def external_agent_config_import( - self, - migration_items: list[ExternalAgentConfigMigrationItem], - ) -> None: - """Import external agent configurations. - - Args: - migration_items: List of migration items to import - """ - params = ExternalAgentConfigImportParams(migration_items=migration_items) - await self._send_request("externalAgentConfig/import", params) - - # ======================================================================== - # Internal transport methods - # ======================================================================== - - async def _send_request(self, method: str, params: BaseModel | None = None) -> Any: - """Send a JSON-RPC request and wait for response. - - Args: - method: JSON-RPC method name - params: Pydantic model with request parameters (will be serialized) - - Returns: - Response result (not yet validated - caller should validate) - """ - if self._process is None or self._process.stdin is None: - raise CodexProcessError("Not connected to Codex app-server") - - request_id = self._request_id - self._request_id += 1 - future: asyncio.Future[Any] = asyncio.Future() - self._pending_requests[request_id] = future - # Serialize params to dict if provided - params_dict: dict[str, Any] = {} - if params is not None: - params_dict = params.model_dump(by_alias=True, exclude_none=True) - - request = JsonRpcRequest(id=request_id, method=method, params=params_dict) - try: - data = request.model_dump_json(by_alias=True, exclude_none=True) - message = anyenv.load_json(data, return_type=dict) - await self._write_message(message) - except Exception as exc: - del self._pending_requests[request_id] - raise CodexProcessError(f"Failed to send request: {exc}") from exc - - return await future - - async def _read_loop(self) -> None: - """Read messages from app-server stdout.""" - if self._process is None or self._process.stdout is None: - return - - try: - while True: - line_bytes = await self._process.stdout.readline() - if not line_bytes: - break - - line = line_bytes.decode().strip() - if not line or line == "null": - continue - - try: - message = anyenv.load_json(line, return_type=dict) - await self._process_message(message) - except json.JSONDecodeError: - logger.warning("Failed to parse JSON: %s", line) - except Exception: - logger.exception("Error processing message") - - except asyncio.CancelledError: - pass - except Exception: - logger.exception("Reader loop failed") - finally: - await self._event_queue.put(None) - - async def _process_message(self, message: dict[str, Any]) -> None: - """Process a message from the app-server. - - Messages are one of: - - Server request: has both "method" and "id" -> needs a response - - Response to our request: has "id" but no "method" -> resolves pending future - - Notification: has "method" but no "id" -> routed as event - - Args: - message: Raw JSON-RPC message - """ - match message: - case {"method": _, "id": _}: # Server request - the server is asking us to do something - await self._handle_server_request(message) - case {"id": _}: # Response to one of our requests - self._handle_response(message) - case {"method": _}: # Notification - one-way event - await self._handle_notification(message) - case _: - raise TypeError(f"Unknown message shape {message}") - - def _handle_response(self, message: dict[str, Any]) -> None: - """Handle a JSON-RPC response to one of our pending requests.""" - msg_id = message["id"] - try: - response = JsonRpcResponse.model_validate(message) - future = self._pending_requests.pop(response.id, None) - if future and not future.done(): - if err := response.error: - future.set_exception(CodexRequestError(err.code, err.message, err.data)) - else: - future.set_result(response.result) - except Exception as exc: # noqa: BLE001 - logger.warning("Failed to parse response: %s", exc) - if isinstance(msg_id, int): - future = self._pending_requests.pop(msg_id, None) - if future and not future.done(): - future.set_result(message.get("result")) - - async def _handle_notification(self, message: dict[str, Any]) -> None: - """Handle a JSON-RPC notification (one-way event).""" - method = message["method"] - params = message.get("params") or {} - event = parse_codex_event(method, params) - # Skip legacy V1 events (parse_codex_event returns None for these) - if event is None: - return - # Route event to appropriate turn queue - thread_id = params.get("threadId") - turn_id = params.get("turnId") - # Also check nested turn object (some events have it there) - if not turn_id and "turn" in params: - turn_data = params.get("turn", {}) - turn_id = turn_data.get("id") - - if thread_id and turn_id: - # Turn-specific event - route to turn queue - turn_key = f"{thread_id}:{turn_id}" - if turn_key in self._turn_queues: - await self._turn_queues[turn_key].put(event) - else: - # Turn queue not found (might be old event) - put in global queue - await self._event_queue.put(event) - else: - # Global event (account, MCP, etc.) - put in global queue - await self._event_queue.put(event) - - async def _handle_server_request(self, message: dict[str, Any]) -> None: - """Handle a JSON-RPC request from the server that expects a response. - - Server requests include: - - item/commandExecution/requestApproval - - item/fileChange/requestApproval - - item/tool/requestUserInput - - item/tool/call (dynamic tool calls) - - account/chatgptAuthTokens/refresh - """ - method: str = message["method"] - request_id = message["id"] - params = message.get("params") or {} - - type_entry = _SERVER_REQUEST_TYPES.get(method) - if type_entry is None: - logger.warning("Unhandled server request method: %s (id=%s)", method, request_id) - await self._send_server_request_error(request_id, -32601, f"Method not found: {method}") - return - - params_type, _ = type_entry - handler = self._server_request_handlers.get(method) - - if handler is None: - logger.warning( - "No handler registered for server request: %s (id=%s)", method, request_id - ) - await self._send_server_request_error(request_id, -32603, f"No handler for: {method}") - return - - try: - parsed_params = params_type.model_validate(params) - response_model = await handler(parsed_params) - await self._send_server_request_response(request_id, response_model) - except Exception: - logger.exception("Error handling server request %s (id=%s)", method, request_id) - await self._send_server_request_error( - request_id, -32603, f"Internal error handling {method}" - ) - - async def _send_server_request_response(self, request_id: int | str, result: BaseModel) -> None: - """Send a JSON-RPC response to a server request.""" - dct = result.model_dump(by_alias=True, exclude_none=True) - response = {"jsonrpc": "2.0", "id": request_id, "result": dct} - await self._write_message(response) - - async def _send_server_request_error( - self, request_id: int | str, code: int, message: str - ) -> None: - """Send a JSON-RPC error response to a server request.""" - error = {"code": code, "message": message} - response = {"jsonrpc": "2.0", "id": request_id, "error": error} - await self._write_message(response) - - async def _write_message(self, message: dict[str, Any]) -> None: - """Write a JSON message to the app-server stdin.""" - if self._process is None or self._process.stdin is None: - raise CodexProcessError("Not connected to Codex app-server") - async with self._writer_lock: - line = json.dumps(message) + "\n" - self._process.stdin.write(line.encode()) - await self._process.stdin.drain() - - # ======================================================================== - # Server request handler registration - # ======================================================================== - - def on_server_request(self, method: str, handler: ServerRequestHandler) -> None: - """Register a handler for a server request method. - - The handler receives the parsed params model and must return - the appropriate response model. - - Args: - method: Server request method name (use SERVER_REQUEST_* constants) - handler: Async callback that processes the request and returns a response - - Example:: - - async def handle_approval( - params: CommandExecutionRequestApprovalParams, - ) -> CommandExecutionRequestApprovalResponse: - return CommandExecutionRequestApprovalResponse(decision="allow") - - client.on_server_request(SERVER_REQUEST_COMMAND_APPROVAL, handle_approval) - """ - if method not in _SERVER_REQUEST_TYPES: - msg = ( - f"Unknown server request method: {method}. " - f"Valid methods: {list(_SERVER_REQUEST_TYPES)}" - ) - raise ValueError(msg) - self._server_request_handlers[method] = handler - - def set_auto_approve(self) -> None: - """Register default handlers that auto-approve all server requests. - - Convenience method for non-interactive use cases where all approvals - should be automatically granted and tool calls return empty results. - """ - - async def auto_approve_command( - _params: ServerRequestParams, - ) -> ServerRequestResponse: - return CommandExecutionRequestApprovalResponse(decision="allow") - - async def auto_approve_file_change( - _params: ServerRequestParams, - ) -> ServerRequestResponse: - return FileChangeRequestApprovalResponse(decision="allow") - - async def auto_approve_user_input( - _params: ServerRequestParams, - ) -> ServerRequestResponse: - return ToolRequestUserInputResponse(answers={}) - - async def auto_approve_dynamic_tool( - _params: ServerRequestParams, - ) -> ServerRequestResponse: - return DynamicToolCallResponse(content_items=[], success=False) - - self._server_request_handlers[SERVER_REQUEST_COMMAND_APPROVAL] = auto_approve_command - self._server_request_handlers[SERVER_REQUEST_FILE_CHANGE_APPROVAL] = ( - auto_approve_file_change - ) - self._server_request_handlers[SERVER_REQUEST_USER_INPUT] = auto_approve_user_input - self._server_request_handlers[SERVER_REQUEST_DYNAMIC_TOOL_CALL] = auto_approve_dynamic_tool - - -if __name__ == "__main__": - - async def main() -> None: - async with CodexClient() as client: - response = await client.thread_start() - async for e in client.turn_stream(response.thread.id, "Show available tools"): - print(e) - - asyncio.run(main()) diff --git a/src/codex_adapter/example.py b/src/codex_adapter/example.py deleted file mode 100644 index 7d3b6a8ec..000000000 --- a/src/codex_adapter/example.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Example usage of the Codex adapter.""" - -from __future__ import annotations - -import asyncio -import sys -from typing import TYPE_CHECKING, Any - -from codex_adapter import CodexClient -from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - CommandExecutionOutputDeltaEvent, - ItemCompletedEvent, - RawResponseItemCompletedEvent, - TurnCompletedEvent, - TurnErrorEvent, - get_text_delta, - is_completed_event, - is_delta_event, - is_error_event, -) - - -if TYPE_CHECKING: - from collections.abc import Callable - - -async def simple_chat() -> None: - """Simple single-turn chat example.""" - print("=== Simple Chat Example ===\n") - - async with CodexClient() as client: - # Start a thread - response = await client.thread_start(cwd=".") - thread_id = response.thread.id - print(f"Started thread: {thread_id}\n") - - # Send a message - message = "List the Python files in the current directory" - print(f"> {message}\n") - - async for event in client.turn_stream(thread_id, message): - # Print agent messages - match event: - case AgentMessageDeltaEvent(): - print(get_text_delta(event), end="", flush=True) - - case CommandExecutionOutputDeltaEvent(): - if delta := get_text_delta(event): - print(f"\n[Command output]\n{delta}", flush=True) - - case TurnCompletedEvent(): - print("\n\n[Turn completed]") - break - - case TurnErrorEvent(data=data): - print(f"\n\n[Error: {data.error}]", file=sys.stderr) - break - - -async def multi_turn_chat() -> None: - """Multi-turn conversation example.""" - print("=== Multi-Turn Chat Example ===\n") - - async with CodexClient() as client: - response = await client.thread_start(cwd=".", model="gpt-5-codex") - thread_id = response.thread.id - - messages = [ - "What is the main purpose of this codebase?", - "Show me the entry point file", - "What dependencies does it use?", - ] - - for i, message in enumerate(messages, 1): - print(f"\n--- Turn {i} ---") - print(f"> {message}\n") - - async for event in client.turn_stream(thread_id, message): - match event: - case AgentMessageDeltaEvent(): - print(get_text_delta(event), end="", flush=True) - case TurnCompletedEvent(): - print("\n") - break - - -async def model_override_example() -> None: - """Example showing per-turn model override.""" - print("=== Model Override Example ===\n") - - async with CodexClient() as client: - response = await client.thread_start(model="gpt-5-codex") - thread_id = response.thread.id - - # First turn with default model - print("Turn 1 (default model: gpt-5-codex)") - print("> Write a hello world function\n") - - async for event in client.turn_stream(thread_id, "Write a hello world function"): - match event: - case AgentMessageDeltaEvent(): - print(get_text_delta(event), end="", flush=True) - case TurnCompletedEvent(): - print("\n") - break - - # Second turn with different model - print("\nTurn 2 (override to claude-opus-4, high effort)") - print("> Now make it more elegant\n") - - async for event in client.turn_stream( - thread_id, - "Now make it more elegant", - model="claude-opus-4", - effort="high", - ): - match event: - case AgentMessageDeltaEvent(): - print(get_text_delta(event), end="", flush=True) - case TurnCompletedEvent(): - print("\n") - break - - -async def event_inspection_example() -> None: - """Example showing detailed event inspection.""" - print("=== Event Inspection Example ===\n") - - async with CodexClient() as client: - response = await client.thread_start(cwd=".") - thread_id = response.thread.id - - async for event in client.turn_stream(thread_id, "What files are here?"): - # Print all event types - print(f"[{event.event_type}]", end=" ") - - # Show event-specific details - if is_delta_event(event): - text = get_text_delta(event) - if text: - print(f"text: {text[:50]}...") - else: - print(f"data: {event.data}") - elif is_completed_event(event): - # Get ID from different event types with proper type safety - match event: - case ItemCompletedEvent() | RawResponseItemCompletedEvent(): - print("✓ item") - case TurnCompletedEvent(data=data): - print(f"✓ turn:{data.turn.id}") - case _: - print("✓") - elif is_error_event(event): - print(f"✗ {event.data}") - else: - print(event.data) - - if isinstance(event, TurnCompletedEvent): - break - - -async def main() -> None: - """Run all examples.""" - examples: list[tuple[str, Callable[[], Any]]] = [ - # ("Simple Chat", simple_chat), - # ("Multi-Turn Chat", multi_turn_chat), - # ("Model Override", model_override_example), - ("Event Inspection", event_inspection_example), - ] - - if len(sys.argv) > 1: - # Run specific example by number - try: - idx = int(sys.argv[1]) - 1 - if 0 <= idx < len(examples): - name, func = examples[idx] - print(f"Running: {name}\n") - await func() - else: - print(f"Invalid example number. Choose 1-{len(examples)}") - except ValueError: - print("Usage: python example.py [example_number]") - else: - # Run all examples - print("Codex Adapter Examples") - print("=" * 50) - print("Running all examples...\n") - - for i, (name, func) in enumerate(examples, 1): - print(f"\n{'=' * 50}") - print(f"Example {i}: {name}") - print("=" * 50) - await func() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/codex_adapter/exceptions.py b/src/codex_adapter/exceptions.py deleted file mode 100644 index 97e38ef7f..000000000 --- a/src/codex_adapter/exceptions.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Codex adapter exceptions.""" - -from __future__ import annotations - -from typing import Any - - -class CodexError(Exception): - """Base exception for Codex adapter errors.""" - - -class CodexProcessError(CodexError): - """Error starting or communicating with the Codex app-server process.""" - - -class CodexRequestError(CodexError): - """Error from a Codex app-server request (JSON-RPC error response).""" - - def __init__(self, code: int, message: str, data: dict[str, Any] | None = None) -> None: - super().__init__(message) - self.code = code - self.message = message - self.data = data or {} - - def __str__(self) -> str: - if self.data: - return f"[{self.code}] {self.message}: {self.data}" - return f"[{self.code}] {self.message}" diff --git a/src/codex_adapter/models/__init__.py b/src/codex_adapter/models/__init__.py deleted file mode 100644 index 12c45b11b..000000000 --- a/src/codex_adapter/models/__init__.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Codex adapter models.""" - -from __future__ import annotations - -# Import order matters: misc ↔ thread_item have circular imports at module level. -# By importing misc first (which triggers thread_item), both modules are fully -# initialized before event_data or events try to use them. -from codex_adapter.models.misc import ( - AppBranding, - AppInfo, - AppMetadata, - AppReview, - AppScreenshot, - ClientInfo, - ConfigEdit, - ConfigLayer, - ConfigLayerMetadata, - ConfigRequirements, - CreditsSnapshot, - ExecPolicyAmendment, - ExperimentalFeature, - ExternalAgentConfigMigrationItem, - GitInfo, - McpResource, - McpResourceTemplate, - McpServerStatusEntry, - McpTool, - ModelAvailabilityNux, - ModelData, - ModelUpgradeInfo, - NetworkApprovalContext, - NetworkPolicyAmendment, - NetworkRequirements, - RateLimitSnapshot, - RateLimitWindow, - ReasoningEffortOption, - SkillData, - SkillDependencies, - SkillErrorInfo, - SkillInterface, - SkillRequestApprovalResponse, - SkillToolDependency, - SkillsContainer, - Thread, - ThreadData, - ToolRequestUserInputAnswer, - ToolRequestUserInputOption, - ToolRequestUserInputQuestion, - Turn, - TurnData, - TurnError, - TurnPlanStep, -) -from codex_adapter.models.token_usage import ThreadTokenUsage, TokenUsageBreakdown, Usage -from codex_adapter.models.misc import PlanStepStatus as MiscPlanStepStatus -from codex_adapter.models.misc import TurnStatus as MiscTurnStatus -from codex_adapter.models.misc import TurnStatusValue as MiscTurnStatusValue -from codex_adapter.models.mcp_server import HttpMcpServer, McpServerConfig, StdioMcpServer -from codex_adapter.models.base import ( - CodexBaseModel, - JsonRpcError, - JsonRpcNotification, - JsonRpcRequest, - JsonRpcResponse, -) -from codex_adapter.models.codex_types import ( - ApprovalPolicy, - AskForApproval, - CollaborationMode, - CollaborationModeSettings, - DangerFullAccessSandboxPolicy, - ExternalSandboxPolicy, - FullAccessReadOnlyAccess, - CollabAgentStatus, - CollabAgentTool, - CollabAgentToolCallStatus, - CommandExecutionApprovalDecision, - CommandExecutionStatus, - DynamicToolCallStatus, - ElicitationAction, - ExperimentalFeatureStage, - ExternalAgentConfigMigrationItemType, - FileChangeApprovalDecision, - InputModality, - ItemStatus, - ItemType, - McpAuthStatusValue, - McpToolCallStatus, - MergeStrategy, - MessagePhase, - ModelProvider, - ModelRerouteReason, - NetworkApprovalProtocol, - NetworkPolicyRuleAction, - PatchApplyStatus, - Personality, - PlanType, - ReasoningEffort, - ReasoningSummary, - ReviewDelivery, - NetworkAccess, - ReadOnlyAccess, - ReadOnlySandboxPolicy, - RejectApprovalPolicy, - RejectConfig, - RestrictedReadOnlyAccess, - SandboxMode, - SandboxPolicy, - ModeKind, - SessionSource, - SkillApprovalDecision, - SkillScope, - ThreadActiveFlag, - WorkspaceWriteSandboxPolicy, - ThreadSortKey, - ThreadSourceKind, - TurnStatus, - WriteStatus, -) -from codex_adapter.models.command_action import ( - CommandAction, - CommandActionListFiles, - CommandActionRead, - CommandActionSearch, - CommandActionUnknown, -) -from codex_adapter.models.event_data import ( - AccountLoginCompletedData, - AccountRateLimitsUpdatedData, - AccountUpdatedData, - AgentMessageDeltaData, - AppListUpdatedData, - AuthStatusChangeData, - CommandExecutionOutputDeltaData, - CommandExecutionTerminalInteractionData, - ConfigWarningData, - ContextCompactedData, - DeprecationNoticeData, - ErrorEventData, - EventData, - FileChangeOutputDeltaData, - ItemCompletedData, - ItemStartedData, - LoginChatGptCompleteData, - McpServerOAuthLoginCompletedData, - McpToolCallProgressData, - ModelReroutedData, - PlanDeltaData, - RawResponseItemCompletedData, - ReasoningSummaryPartAddedData, - ReasoningSummaryTextDeltaData, - ReasoningTextDeltaData, - ServerRequestResolvedData, - SessionConfiguredData, - ThreadArchivedData, - ThreadCompactedData, - ThreadNameUpdatedData, - ThreadStartedData, - ThreadStatusChangedData, - ThreadTokenUsageUpdatedData, - ThreadUnarchivedData, - TurnCompletedData, - TurnDiffUpdatedData, - TurnErrorData, - TurnPlanUpdatedData, - TurnStartedData, - WindowsWorldWritableWarningData, -) -from codex_adapter.models.events import ( - AccountLoginCompletedEvent, - AccountRateLimitsUpdatedEvent, - AccountUpdatedEvent, - AgentMessageDeltaEvent, - AppListUpdatedEvent, - AuthStatusChangeEvent, - CodexEvent, - CommandExecutionOutputDeltaEvent, - CommandExecutionTerminalInteractionEvent, - ConfigWarningEvent, - ContextCompactedEvent, - DeltaEvent, - DeprecationNoticeEvent, - ErrorEvent, - EventType, - FileChangeOutputDeltaEvent, - ItemCompletedEvent, - ItemStartedEvent, - LoginChatGptCompleteEvent, - McpServerOAuthLoginCompletedEvent, - McpToolCallProgressEvent, - ModelReroutedEvent, - PlanDeltaEvent, - RawResponseItemCompletedEvent, - ReasoningSummaryPartAddedEvent, - ReasoningSummaryTextDeltaEvent, - ReasoningTextDeltaEvent, - ServerRequestResolvedEvent, - SessionConfiguredEvent, - ThreadArchivedEvent, - ThreadCompactedEvent, - ThreadNameUpdatedEvent, - ThreadStartedEvent, - ThreadStatusChangedEvent, - ThreadTokenUsageUpdatedEvent, - ThreadUnarchivedEvent, - TurnCompletedEvent, - TurnDiffUpdatedEvent, - TurnErrorEvent, - TurnPlanUpdatedEvent, - TurnStartedEvent, - WindowsWorldWritableWarningEvent, - get_text_delta, - is_completed_event, - is_delta_event, - is_error_event, - parse_codex_event, -) -from codex_adapter.models.input_item import ( - ImageInputItem, - LocalImageInputItem, - MentionInputItem, - SkillInputItem, - TextInputItem, - TurnInputItem, -) -from codex_adapter.models.request_params import ( - AppsListParams, - CancelLoginAccountParams, - CommandExecParams, - CommandExecutionRequestApprovalParams, - ConfigBatchWriteParams, - ConfigReadParams, - ConfigValueWriteParams, - DynamicToolCallParams, - ExperimentalFeatureListParams, - ExternalAgentConfigDetectParams, - ExternalAgentConfigImportParams, - FeedbackUploadParams, - FileChangeRequestApprovalParams, - GetAccountParams, - InitializeParams, - ListMcpServerStatusParams, - LoginAccountParams, - LoginType, - McpServerOauthLoginParams, - ModelListParams, - ReviewStartParams, - SkillRequestApprovalParams, - SkillsConfigWriteParams, - SkillsListParams, - ThreadArchiveParams, - ThreadCompactStartParams, - ThreadForkParams, - ThreadListParams, - ThreadLoadedListParams, - ThreadReadParams, - ThreadResumeParams, - ThreadRollbackParams, - ThreadSetNameParams, - ThreadStartParams, - ThreadUnarchiveParams, - ThreadUnsubscribeParams, - ToolRequestUserInputParams, - CollaborationModeListParams, - SkillsRemoteListParams, - SkillsRemoteExportParams, - TurnInterruptParams, - TurnStartParams, - TurnSteerParams, -) -from codex_adapter.models.responses import ( - AppsListResponse, - CancelLoginAccountResponse, - CommandExecResponse, - CommandExecutionRequestApprovalResponse, - ConfigReadResponse, - ConfigRequirementsReadResponse, - ConfigWriteResponse, - DynamicToolCallResponse, - ExperimentalFeatureListResponse, - ExternalAgentConfigDetectResponse, - FeedbackUploadResponse, - FileChangeRequestApprovalResponse, - GetAccountRateLimitsResponse, - GetAccountResponse, - ListMcpServerStatusResponse, - LoginAccountResponse, - McpServerOauthLoginResponse, - McpServerRefreshResponse, - ModelListResponse, - ReviewStartResponse, - SkillsConfigWriteResponse, - SkillsListResponse, - SkillsRemoteListResponse, - SkillsRemoteExportResponse, - RemoteSkillSummary, - ThreadListResponse, - ThreadLoadedListResponse, - ThreadReadResponse, - ThreadResponse, - ThreadRollbackResponse, - ThreadUnarchiveResponse, - ThreadUnsubscribeResponse, - CollaborationModeMask, - CollaborationModeListResponse, - ToolRequestUserInputResponse, - TurnStartResponse, - TurnSteerResponse, -) -from codex_adapter.models.thread_item import ( - CollabAgentState, - DynamicToolCallOutputContentItem, - FileUpdateChange, - McpToolCallError, - McpToolCallResult, - PatchChangeKind, - ThreadItem, - ThreadItemAgentMessage, - ThreadItemCollabAgentToolCall, - ThreadItemCommandExecution, - ThreadItemContextCompaction, - ThreadItemDynamicToolCall, - ThreadItemEnteredReviewMode, - ThreadItemExitedReviewMode, - ThreadItemFileChange, - ThreadItemImageView, - ThreadItemMcpToolCall, - ThreadItemPlan, - ThreadItemReasoning, - ThreadItemUserMessage, - ThreadItemWebSearch, -) -from codex_adapter.models.thread_status import ( - ThreadStatusActive, - ThreadStatusIdle, - ThreadStatusNotLoaded, - ThreadStatusSystemError, -) -from codex_adapter.models.thread_status import ThreadStatusValue as ThreadStatusUnion -from codex_adapter.models.user_input import ( - ByteRange, - TextElement, - UserInput, - UserInputImage, - UserInputLocalImage, - UserInputMention, - UserInputSkill, - UserInputText, -) -from codex_adapter.models.web_search import ( - WebSearchAction, - WebSearchActionFindInPage, - WebSearchActionOpenPage, - WebSearchActionOther, - WebSearchActionSearch, -) - -__all__ = [ - "AccountLoginCompletedData", - "AccountLoginCompletedEvent", - "AccountRateLimitsUpdatedData", - "AccountRateLimitsUpdatedEvent", - "AccountUpdatedData", - "AccountUpdatedEvent", - "AgentMessageDeltaData", - "AgentMessageDeltaEvent", - "AppBranding", - "AppInfo", - "AppListUpdatedData", - "AppListUpdatedEvent", - "AppMetadata", - "AppReview", - "AppScreenshot", - "ApprovalPolicy", - "AppsListParams", - "AppsListResponse", - "AskForApproval", - "AuthStatusChangeData", - "AuthStatusChangeEvent", - "ByteRange", - "CancelLoginAccountParams", - "CancelLoginAccountResponse", - "ClientInfo", - "CodexBaseModel", - "CodexEvent", - "CollabAgentState", - "CollabAgentStatus", - "CollabAgentTool", - "CollabAgentToolCallStatus", - "CollaborationMode", - "CollaborationModeListParams", - "CollaborationModeListResponse", - "CollaborationModeMask", - "CollaborationModeSettings", - "CommandAction", - "CommandActionListFiles", - "CommandActionRead", - "CommandActionSearch", - "CommandActionUnknown", - "CommandExecParams", - "CommandExecResponse", - "CommandExecutionApprovalDecision", - "CommandExecutionOutputDeltaData", - "CommandExecutionOutputDeltaEvent", - "CommandExecutionRequestApprovalParams", - "CommandExecutionRequestApprovalResponse", - "CommandExecutionStatus", - "CommandExecutionTerminalInteractionData", - "CommandExecutionTerminalInteractionEvent", - "ConfigBatchWriteParams", - "ConfigEdit", - "ConfigLayer", - "ConfigLayerMetadata", - "ConfigReadParams", - "ConfigReadResponse", - "ConfigRequirements", - "ConfigRequirementsReadResponse", - "ConfigValueWriteParams", - "ConfigWarningData", - "ConfigWarningEvent", - "ConfigWriteResponse", - "ContextCompactedData", - "ContextCompactedEvent", - "CreditsSnapshot", - "DangerFullAccessSandboxPolicy", - "DeltaEvent", - "DeprecationNoticeData", - "DeprecationNoticeEvent", - "DynamicToolCallOutputContentItem", - "DynamicToolCallParams", - "DynamicToolCallResponse", - "DynamicToolCallStatus", - "ElicitationAction", - "ErrorEvent", - "ErrorEventData", - "EventData", - "EventType", - "ExecPolicyAmendment", - "ExperimentalFeature", - "ExperimentalFeatureListParams", - "ExperimentalFeatureListResponse", - "ExperimentalFeatureStage", - "ExternalAgentConfigDetectParams", - "ExternalAgentConfigDetectResponse", - "ExternalAgentConfigImportParams", - "ExternalAgentConfigMigrationItem", - "ExternalAgentConfigMigrationItemType", - "ExternalSandboxPolicy", - "FeedbackUploadParams", - "FeedbackUploadResponse", - "FileChangeApprovalDecision", - "FileChangeOutputDeltaData", - "FileChangeOutputDeltaEvent", - "FileChangeRequestApprovalParams", - "FileChangeRequestApprovalResponse", - "FileUpdateChange", - "FullAccessReadOnlyAccess", - "GetAccountParams", - "GetAccountRateLimitsResponse", - "GetAccountResponse", - "GitInfo", - "HttpMcpServer", - "ImageInputItem", - "InitializeParams", - "InputModality", - "ItemCompletedData", - "ItemCompletedEvent", - "ItemStartedData", - "ItemStartedEvent", - "ItemStatus", - "ItemType", - "JsonRpcError", - "JsonRpcNotification", - "JsonRpcRequest", - "JsonRpcResponse", - "ListMcpServerStatusParams", - "ListMcpServerStatusResponse", - "LocalImageInputItem", - "LoginAccountParams", - "LoginAccountResponse", - "LoginChatGptCompleteData", - "LoginChatGptCompleteEvent", - "LoginType", - "McpAuthStatusValue", - "McpResource", - "McpResourceTemplate", - "McpServerConfig", - "McpServerOAuthLoginCompletedData", - "McpServerOAuthLoginCompletedEvent", - "McpServerOauthLoginParams", - "McpServerOauthLoginResponse", - "McpServerRefreshResponse", - "McpServerStatusEntry", - "McpTool", - "McpToolCallError", - "McpToolCallProgressData", - "McpToolCallProgressEvent", - "McpToolCallResult", - "McpToolCallStatus", - "MentionInputItem", - "MergeStrategy", - "MessagePhase", - "MiscPlanStepStatus", - "MiscTurnStatus", - "MiscTurnStatusValue", - "ModeKind", - "ModelAvailabilityNux", - "ModelData", - "ModelListParams", - "ModelListResponse", - "ModelProvider", - "ModelRerouteReason", - "ModelReroutedData", - "ModelReroutedEvent", - "ModelUpgradeInfo", - "NetworkAccess", - "NetworkApprovalContext", - "NetworkApprovalProtocol", - "NetworkPolicyAmendment", - "NetworkPolicyRuleAction", - "NetworkRequirements", - "PatchApplyStatus", - "PatchChangeKind", - "Personality", - "PlanDeltaData", - "PlanDeltaEvent", - "PlanType", - "RateLimitSnapshot", - "RateLimitWindow", - "RawResponseItemCompletedData", - "RawResponseItemCompletedEvent", - "ReadOnlyAccess", - "ReadOnlySandboxPolicy", - "ReasoningEffort", - "ReasoningEffortOption", - "ReasoningSummary", - "ReasoningSummaryPartAddedData", - "ReasoningSummaryPartAddedEvent", - "ReasoningSummaryTextDeltaData", - "ReasoningSummaryTextDeltaEvent", - "ReasoningTextDeltaData", - "ReasoningTextDeltaEvent", - "RejectApprovalPolicy", - "RejectConfig", - "RemoteSkillSummary", - "RestrictedReadOnlyAccess", - "ReviewDelivery", - "ReviewStartParams", - "ReviewStartResponse", - "SandboxMode", - "SandboxPolicy", - "ServerRequestResolvedData", - "ServerRequestResolvedEvent", - "SessionConfiguredData", - "SessionConfiguredEvent", - "SessionSource", - "SkillApprovalDecision", - "SkillData", - "SkillDependencies", - "SkillErrorInfo", - "SkillInputItem", - "SkillInterface", - "SkillRequestApprovalParams", - "SkillRequestApprovalResponse", - "SkillScope", - "SkillToolDependency", - "SkillsConfigWriteParams", - "SkillsConfigWriteResponse", - "SkillsContainer", - "SkillsListParams", - "SkillsListResponse", - "SkillsRemoteExportParams", - "SkillsRemoteExportResponse", - "SkillsRemoteListParams", - "SkillsRemoteListResponse", - "StdioMcpServer", - "TextElement", - "TextInputItem", - "Thread", - "ThreadActiveFlag", - "ThreadArchiveParams", - "ThreadArchivedData", - "ThreadArchivedEvent", - "ThreadCompactStartParams", - "ThreadCompactedData", - "ThreadCompactedEvent", - "ThreadData", - "ThreadForkParams", - "ThreadItem", - "ThreadItemAgentMessage", - "ThreadItemCollabAgentToolCall", - "ThreadItemCommandExecution", - "ThreadItemContextCompaction", - "ThreadItemDynamicToolCall", - "ThreadItemEnteredReviewMode", - "ThreadItemExitedReviewMode", - "ThreadItemFileChange", - "ThreadItemImageView", - "ThreadItemMcpToolCall", - "ThreadItemPlan", - "ThreadItemReasoning", - "ThreadItemUserMessage", - "ThreadItemWebSearch", - "ThreadListParams", - "ThreadListResponse", - "ThreadLoadedListParams", - "ThreadLoadedListResponse", - "ThreadNameUpdatedData", - "ThreadNameUpdatedEvent", - "ThreadReadParams", - "ThreadReadResponse", - "ThreadResponse", - "ThreadResumeParams", - "ThreadRollbackParams", - "ThreadRollbackResponse", - "ThreadSetNameParams", - "ThreadSortKey", - "ThreadSourceKind", - "ThreadStartParams", - "ThreadStartedData", - "ThreadStartedEvent", - "ThreadStatusActive", - "ThreadStatusChangedData", - "ThreadStatusChangedEvent", - "ThreadStatusIdle", - "ThreadStatusNotLoaded", - "ThreadStatusSystemError", - "ThreadStatusUnion", - "ThreadTokenUsage", - "ThreadTokenUsageUpdatedData", - "ThreadTokenUsageUpdatedEvent", - "ThreadUnarchiveParams", - "ThreadUnarchiveResponse", - "ThreadUnarchivedData", - "ThreadUnarchivedEvent", - "ThreadUnsubscribeParams", - "ThreadUnsubscribeResponse", - "TokenUsageBreakdown", - "ToolRequestUserInputAnswer", - "ToolRequestUserInputOption", - "ToolRequestUserInputParams", - "ToolRequestUserInputQuestion", - "ToolRequestUserInputResponse", - "Turn", - "TurnCompletedData", - "TurnCompletedEvent", - "TurnData", - "TurnDiffUpdatedData", - "TurnDiffUpdatedEvent", - "TurnError", - "TurnErrorData", - "TurnErrorEvent", - "TurnInputItem", - "TurnInterruptParams", - "TurnPlanStep", - "TurnPlanUpdatedData", - "TurnPlanUpdatedEvent", - "TurnStartParams", - "TurnStartResponse", - "TurnStartedData", - "TurnStartedEvent", - "TurnStatus", - "TurnSteerParams", - "TurnSteerResponse", - "Usage", - "UserInput", - "UserInputImage", - "UserInputLocalImage", - "UserInputMention", - "UserInputSkill", - "UserInputText", - "WebSearchAction", - "WebSearchActionFindInPage", - "WebSearchActionOpenPage", - "WebSearchActionOther", - "WebSearchActionSearch", - "WindowsWorldWritableWarningData", - "WindowsWorldWritableWarningEvent", - "WorkspaceWriteSandboxPolicy", - "WriteStatus", - "get_text_delta", - "is_completed_event", - "is_delta_event", - "is_error_event", - "parse_codex_event", -] diff --git a/src/codex_adapter/models/base.py b/src/codex_adapter/models/base.py deleted file mode 100644 index be22cef22..000000000 --- a/src/codex_adapter/models/base.py +++ /dev/null @@ -1,59 +0,0 @@ -import sys -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field -from pydantic.alias_generators import to_camel - - -IS_DEV = "pytest" in sys.modules - - -class CodexBaseModel(BaseModel): - """Base model for all Codex API models. - - Provides: - - Strict validation in tests (forbids extra fields to catch schema changes) - - Lenient validation in production (ignores extra fields for forward compat) - - Snake_case Python fields with camelCase JSON aliases - - Both field names and aliases accepted for parsing (populate_by_name=True) - """ - - model_config = ConfigDict( - extra="forbid" if IS_DEV else "ignore", - populate_by_name=True, - alias_generator=to_camel, - ) - - -class JsonRpcRequest(CodexBaseModel): - """JSON-RPC 2.0 request message.""" - - jsonrpc: Literal["2.0"] = "2.0" - id: int - method: str - params: dict[str, Any] = Field(default_factory=dict) # Method-specific params - - -class JsonRpcError(CodexBaseModel): - """JSON-RPC 2.0 error object.""" - - code: int - message: str - data: Any = None - - -class JsonRpcResponse(CodexBaseModel): - """JSON-RPC 2.0 response message.""" - - jsonrpc: Literal["2.0"] = "2.0" - id: int - result: Any = None - error: JsonRpcError | None = None - - -class JsonRpcNotification(CodexBaseModel): - """JSON-RPC 2.0 notification message (no id).""" - - jsonrpc: Literal["2.0"] = "2.0" - method: str - params: dict[str, Any] | None = None # Event-specific params diff --git a/src/codex_adapter/models/codex_types.py b/src/codex_adapter/models/codex_types.py deleted file mode 100644 index 0dc5ec134..000000000 --- a/src/codex_adapter/models/codex_types.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Codex data types.""" - -from __future__ import annotations - -from typing import Annotated, Any, Literal - -from pydantic import BaseModel, Discriminator, Field, Tag - -from codex_adapter.models.base import CodexBaseModel - - -# Type aliases for Codex types -ModelProvider = Literal["openai", "anthropic", "google", "mistral"] -ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] -ReasoningSummary = Literal["auto", "concise", "detailed", "none"] -ApprovalPolicy = Literal["untrusted", "on-failure", "on-request", "never"] -SandboxMode = Literal["read-only", "workspace-write", "danger-full-access"] -NetworkAccess = Literal["restricted", "enabled"] -Personality = Literal["none", "friendly", "pragmatic"] -TurnStatus = Literal["pending", "inProgress", "completed", "error", "interrupted"] -ItemType = Literal[ - "reasoning", - "agent_message", - "command_execution", - "user_message", - "file_change", - "mcp_tool_call", -] -ItemStatus = Literal["pending", "running", "completed", "error"] - -# New type aliases -SessionSource = Literal["cli", "vscode", "exec", "appServer", "unknown"] -ThreadSortKey = Literal["created_at", "updated_at"] -ThreadSourceKind = Literal[ - "cli", - "vscode", - "exec", - "appServer", - "subAgent", - "subAgentReview", - "subAgentCompact", - "subAgentThreadSpawn", - "subAgentOther", - "unknown", -] -MessagePhase = Literal["commentary", "final_answer"] -PatchApplyStatus = Literal["inProgress", "completed", "failed", "declined"] -CommandExecutionStatus = Literal["inProgress", "completed", "failed", "declined"] -McpToolCallStatus = Literal["inProgress", "completed", "failed"] -DynamicToolCallStatus = Literal["inProgress", "completed", "failed"] -CollabAgentTool = Literal["spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent"] -CollabAgentToolCallStatus = Literal["inProgress", "completed", "failed"] -CollabAgentStatus = Literal[ - "pendingInit", "running", "completed", "errored", "shutdown", "notFound" -] -InputModality = Literal["text", "image"] -SkillScope = Literal["user", "repo", "system", "admin"] -McpAuthStatusValue = Literal["Unsupported", "NotAuthenticated", "Authenticated"] -ReviewDelivery = Literal["inline", "detached"] -ThreadActiveFlag = Literal["waitingOnApproval", "waitingOnUserInput"] -CommandExecutionApprovalDecision = Literal["allow", "allowForSession", "deny", "denyForSession"] -FileChangeApprovalDecision = Literal["allow", "allowForSession", "deny", "denyForSession"] -SkillApprovalDecision = Literal["allow", "deny"] -ModelRerouteReason = Literal["rateLimited", "contextWindowExceeded", "other"] -WriteStatus = Literal["ok", "conflict"] -MergeStrategy = Literal["replace", "merge"] -ExperimentalFeatureStage = Literal["alpha", "beta"] -ElicitationAction = Literal["accept", "decline", "cancel"] -NetworkApprovalProtocol = Literal["http", "https", "socks5Tcp", "socks5Udp"] -NetworkPolicyRuleAction = Literal["allow", "deny"] -ExternalAgentConfigMigrationItemType = Literal["AGENTS_MD", "CONFIG", "SKILLS", "MCP_SERVER_CONFIG"] -PlanType = Literal["free", "go", "plus", "pro", "team", "business", "enterprise", "edu", "unknown"] -ModeKind = Literal["plan", "default"] - - -# ============================================================================ -# AskForApproval (tagged union: string literals or {"reject": RejectConfig}) -# ============================================================================ - - -class RejectConfig(CodexBaseModel): - """Fine-grained rejection controls for approval prompts. - - When a field is True, prompts of that category are automatically - rejected instead of shown to the user. - """ - - sandbox_approval: bool - rules: bool - mcp_elicitations: bool - - -class RejectApprovalPolicy(CodexBaseModel): - """Approval policy variant with fine-grained rejection controls.""" - - reject: RejectConfig - - -def _ask_for_approval_discriminator(v: Any) -> str: - match v: - case str(): - return "simple" - case {"reject": _}: - return "reject" - case RejectApprovalPolicy(): - return "reject" - case _: - return "simple" - - -AskForApproval = Annotated[ - Annotated[ApprovalPolicy, Tag("simple")] | Annotated[RejectApprovalPolicy, Tag("reject")], - Discriminator(_ask_for_approval_discriminator), -] -"""Full AskForApproval type: simple string policy or reject config.""" - - -# ============================================================================ -# SandboxPolicy (discriminated union on 'type' field) -# ============================================================================ - - -# Mapping from camelCase type values (turn-level API) to kebab-case (thread-level API) -_SANDBOX_TYPE_ALIASES: dict[str, str] = { - "workspaceWrite": "workspace-write", - "dangerFullAccess": "danger-full-access", - "readOnly": "read-only", - "externalSandbox": "external-sandbox", -} - -_READ_ONLY_ACCESS_TYPE_ALIASES: dict[str, str] = { - "fullAccess": "full-access", -} - - -def _sandbox_policy_discriminator(v: Any) -> str: - match v: - case {"type": str(raw_type)}: - return _SANDBOX_TYPE_ALIASES.get(raw_type, raw_type) - case BaseModel(): - return str(v.model_fields["type"].default) - case _: - return str(v) - - -def _read_only_access_discriminator(v: Any) -> str: - match v: - case {"type": str(raw_type)}: - return _READ_ONLY_ACCESS_TYPE_ALIASES.get(raw_type, raw_type) - case BaseModel(): - return str(v.model_fields["type"].default) - case _: - return str(v) - - -class RestrictedReadOnlyAccess(CodexBaseModel): - """Restrict reads to an explicit set of roots.""" - - type: Literal["restricted"] - readable_roots: list[str] = Field(default_factory=list) - include_platform_defaults: bool = True - - -class FullAccessReadOnlyAccess(CodexBaseModel): - """Allow unrestricted file reads.""" - - type: Literal["full-access", "fullAccess"] - - -ReadOnlyAccess = Annotated[ - Annotated[RestrictedReadOnlyAccess, Tag("restricted")] - | Annotated[FullAccessReadOnlyAccess, Tag("full-access")], - Discriminator(_read_only_access_discriminator), -] - - -class DangerFullAccessSandboxPolicy(CodexBaseModel): - """No restrictions whatsoever. Use with caution.""" - - type: Literal["danger-full-access", "dangerFullAccess"] - - -class ReadOnlySandboxPolicy(CodexBaseModel): - """Read-only access configuration.""" - - type: Literal["read-only", "readOnly"] - access: ReadOnlyAccess | None = None - - -class ExternalSandboxPolicy(CodexBaseModel): - """Process is already in an external sandbox.""" - - type: Literal["external-sandbox", "externalSandbox"] - network_access: NetworkAccess = "restricted" - - -class WorkspaceWriteSandboxPolicy(CodexBaseModel): - """Grants write access to the workspace directory.""" - - type: Literal["workspace-write", "workspaceWrite"] - writable_roots: list[str] = Field(default_factory=list) - read_only_access: ReadOnlyAccess | None = None - network_access: bool = False - exclude_slash_tmp: bool = False - exclude_tmpdir_env_var: bool = False - - -SandboxPolicy = Annotated[ - Annotated[DangerFullAccessSandboxPolicy, Tag("danger-full-access")] - | Annotated[ReadOnlySandboxPolicy, Tag("read-only")] - | Annotated[ExternalSandboxPolicy, Tag("external-sandbox")] - | Annotated[WorkspaceWriteSandboxPolicy, Tag("workspace-write")], - Discriminator(_sandbox_policy_discriminator), -] -"""Discriminated union for sandbox execution restrictions.""" - - -# ============================================================================ -# CollaborationMode (experimental per-turn preset) -# ============================================================================ - - -class CollaborationModeSettings(CodexBaseModel): - """Settings within a collaboration mode preset.""" - - model: str - reasoning_effort: ReasoningEffort | None = None - developer_instructions: str | None = None - - -class CollaborationMode(CodexBaseModel): - """Collaboration mode preset for a turn (experimental). - - Overrides model, reasoning effort, and developer instructions when set. - """ - - mode: ModeKind - settings: CollaborationModeSettings diff --git a/src/codex_adapter/models/command_action.py b/src/codex_adapter/models/command_action.py deleted file mode 100644 index 282bfff44..000000000 --- a/src/codex_adapter/models/command_action.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Pydantic models for Codex JSON-RPC API requests and responses.""" - -from __future__ import annotations - -from typing import Literal - -from codex_adapter.models.base import CodexBaseModel - - -class CommandActionRead(CodexBaseModel): - """Read command action.""" - - type: Literal["read"] = "read" - command: str - name: str - path: str - - -class CommandActionListFiles(CodexBaseModel): - """List files command action.""" - - type: Literal["listFiles"] = "listFiles" - command: str - path: str | None = None - - -class CommandActionSearch(CodexBaseModel): - """Search command action.""" - - type: Literal["search"] = "search" - command: str - query: str | None = None - path: str | None = None - - -class CommandActionUnknown(CodexBaseModel): - """Unknown command action.""" - - type: Literal["unknown"] = "unknown" - command: str - - -# Discriminated union of command actions -CommandAction = ( - CommandActionRead | CommandActionListFiles | CommandActionSearch | CommandActionUnknown -) diff --git a/src/codex_adapter/models/event_data.py b/src/codex_adapter/models/event_data.py deleted file mode 100644 index a66360c46..000000000 --- a/src/codex_adapter/models/event_data.py +++ /dev/null @@ -1,396 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ModelRerouteReason # noqa: TC001 -from codex_adapter.models.misc import ( # noqa: TC001 - AppInfo, - RateLimitSnapshot, - TextRange, - Thread, - Turn, - TurnError, - TurnPlanStep, -) -from codex_adapter.models.thread_item import ThreadItem # noqa: TC001 -from codex_adapter.models.thread_status import ThreadStatusValue # noqa: TC001 -from codex_adapter.models.token_usage import ThreadTokenUsage # noqa: TC001 - - -class TurnPlanUpdatedData(CodexBaseModel): - """Payload for turn/plan/updated notification.""" - - thread_id: str - turn_id: str - explanation: str | None = None - plan: list[TurnPlanStep] - - -# Item lifecycle notifications - - -class ItemStartedData(CodexBaseModel): - """Payload for item/started notification (V2 protocol).""" - - thread_id: str - turn_id: str - item: ThreadItem - - -class ItemCompletedData(CodexBaseModel): - """Payload for item/completed notification (V2 protocol).""" - - thread_id: str - turn_id: str - item: ThreadItem - - -class RawResponseItemCompletedData(CodexBaseModel): - """Payload for rawResponseItem/completed notification.""" - - thread_id: str - turn_id: str - item: ThreadItem - - -# Item delta notifications - - -class AgentMessageDeltaData(CodexBaseModel): - """Payload for item/agentMessage/delta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - - -class PlanDeltaData(CodexBaseModel): - """Payload for item/plan/delta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - - -class ReasoningTextDeltaData(CodexBaseModel): - """Payload for item/reasoning/textDelta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - content_index: int - - -class ReasoningSummaryTextDeltaData(CodexBaseModel): - """Payload for item/reasoning/summaryTextDelta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - summary_index: int - - -class ReasoningSummaryPartAddedData(CodexBaseModel): - """Payload for item/reasoning/summaryPartAdded notification.""" - - thread_id: str - turn_id: str - item_id: str - summary_index: int - - -class CommandExecutionOutputDeltaData(CodexBaseModel): - """Payload for item/commandExecution/outputDelta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - - -class CommandExecutionTerminalInteractionData(CodexBaseModel): - """Payload for item/commandExecution/terminalInteraction notification.""" - - thread_id: str - turn_id: str - item_id: str - process_id: str - stdin: str - - -class FileChangeOutputDeltaData(CodexBaseModel): - """Payload for item/fileChange/outputDelta notification.""" - - thread_id: str - turn_id: str - item_id: str - delta: str - - -class McpToolCallProgressData(CodexBaseModel): - """Payload for item/mcpToolCall/progress notification.""" - - thread_id: str - turn_id: str - item_id: str - message: str - - -# MCP/Account/System notifications - - -class McpServerOAuthLoginCompletedData(CodexBaseModel): - """Payload for mcpServer/oauthLogin/completed notification.""" - - name: str - success: bool - error: str | None = None - - -class ThreadStartedData(CodexBaseModel): - """Payload for thread/started notification (V2 protocol).""" - - thread: Thread - - @property - def thread_id(self) -> str: - """Thread ID derived from the thread object.""" - return self.thread.id - - -class ThreadStatusChangedData(CodexBaseModel): - """Payload for thread/status/changed notification.""" - - thread_id: str - status: ThreadStatusValue - - -class ThreadArchivedData(CodexBaseModel): - """Payload for thread/archived notification.""" - - thread_id: str - - -class ThreadUnarchivedData(CodexBaseModel): - """Payload for thread/unarchived notification.""" - - thread_id: str - - -class ThreadNameUpdatedData(CodexBaseModel): - """Payload for thread/name/updated notification.""" - - thread_id: str - thread_name: str | None = None - - -class ThreadTokenUsageUpdatedData(CodexBaseModel): - """Payload for thread/tokenUsage/updated notification (V2 protocol).""" - - thread_id: str - turn_id: str - token_usage: ThreadTokenUsage - - -class ThreadCompactedData(CodexBaseModel): - """Payload for thread/compacted notification.""" - - thread_id: str - turn_id: str | None = None - - -# Turn lifecycle notifications - - -class TurnStartedData(CodexBaseModel): - """Payload for turn/started notification (V2 protocol).""" - - thread_id: str - turn: Turn - - -class TurnCompletedData(CodexBaseModel): - """Payload for turn/completed notification (V2 protocol).""" - - thread_id: str - turn: Turn - - -class TurnErrorData(CodexBaseModel): - """Payload for turn/error notification.""" - - thread_id: str - turn_id: str - error: str - - -class TurnDiffUpdatedData(CodexBaseModel): - """Payload for turn/diff/updated notification.""" - - thread_id: str - turn_id: str - diff: str - - -class AccountRateLimitsUpdatedData(CodexBaseModel): - """Payload for account/rateLimits/updated notification.""" - - rate_limits: RateLimitSnapshot - - -class AccountLoginCompletedData(CodexBaseModel): - """Payload for account/login/completed notification.""" - - login_id: str | None = None - success: bool - error: str | None = None - - -class AuthStatusChangeData(CodexBaseModel): - """Payload for authStatusChange notification (legacy v1).""" - - status: str - - -class LoginChatGptCompleteData(CodexBaseModel): - """Payload for loginChatGptComplete notification (legacy v1).""" - - success: bool - - -class SessionConfiguredData(CodexBaseModel): - """Payload for sessionConfigured notification.""" - - config: dict[str, Any] # Session config - flexible structure - - -class DeprecationNoticeData(CodexBaseModel): - """Payload for deprecationNotice notification.""" - - summary: str - details: str | None = None - - -class WindowsWorldWritableWarningData(CodexBaseModel): - """Payload for windows/worldWritableWarning notification.""" - - sample_paths: list[str] - extra_count: int - failed_scan: bool - - -class ErrorEventData(CodexBaseModel): - """Payload for error event.""" - - error: TurnError - will_retry: bool - thread_id: str - turn_id: str - - -class ModelReroutedData(CodexBaseModel): - """Payload for model/rerouted notification.""" - - thread_id: str - turn_id: str - from_model: str - to_model: str - reason: ModelRerouteReason - - -class ConfigWarningData(CodexBaseModel): - """Payload for configWarning notification.""" - - summary: str - details: str | None = None - path: str | None = None - range: TextRange | None = None - - -class AppListUpdatedData(CodexBaseModel): - """Payload for app/list/updated notification.""" - - data: list[AppInfo] - - -class ContextCompactedData(CodexBaseModel): - """Payload for thread/compacted/v2 notification.""" - - thread_id: str - turn_id: str | None = None - - -class ServerRequestResolvedData(CodexBaseModel): - """Payload for serverRequest/resolved notification.""" - - thread_id: str - request_id: int | str - - -class AccountUpdatedData(CodexBaseModel): - """Payload for account/updated notification.""" - - auth_mode: str | None = None - - -# Union type of all event data -EventData = ( - # Thread lifecycle - ThreadStartedData - | ThreadStatusChangedData - | ThreadArchivedData - | ThreadUnarchivedData - | ThreadNameUpdatedData - | ThreadTokenUsageUpdatedData - | ThreadCompactedData - # Turn lifecycle - | TurnStartedData - | TurnCompletedData - | TurnErrorData - | TurnDiffUpdatedData - | TurnPlanUpdatedData - # Item lifecycle - | ItemStartedData - | ItemCompletedData - | RawResponseItemCompletedData - # Item deltas - agent messages - | AgentMessageDeltaData - # Item deltas - plan - | PlanDeltaData - # Item deltas - reasoning - | ReasoningTextDeltaData - | ReasoningSummaryTextDeltaData - | ReasoningSummaryPartAddedData - # Item deltas - command execution - | CommandExecutionOutputDeltaData - | CommandExecutionTerminalInteractionData - # Item deltas - file changes - | FileChangeOutputDeltaData - # Item deltas - MCP tool calls - | McpToolCallProgressData - # MCP OAuth - | McpServerOAuthLoginCompletedData - # Account/Auth events - | AccountUpdatedData - | AccountRateLimitsUpdatedData - | AccountLoginCompletedData - | AuthStatusChangeData - | LoginChatGptCompleteData - # System events - | SessionConfiguredData - | DeprecationNoticeData - | WindowsWorldWritableWarningData - # Error events - | ErrorEventData - # New events - | ModelReroutedData - | ConfigWarningData - | AppListUpdatedData - | ContextCompactedData - | ServerRequestResolvedData -) diff --git a/src/codex_adapter/models/events.py b/src/codex_adapter/models/events.py deleted file mode 100644 index 516838466..000000000 --- a/src/codex_adapter/models/events.py +++ /dev/null @@ -1,624 +0,0 @@ -"""Codex event types for streaming. - -Uses discriminated unions with TypeAdapter for type-safe event parsing. -Each event type is a proper BaseModel with the event_type as the discriminator. -""" - -from __future__ import annotations - -from typing import Annotated, Any, Literal - -from pydantic import Field, TypeAdapter - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.event_data import ( # noqa: TC001 - AccountLoginCompletedData, - AccountRateLimitsUpdatedData, - AccountUpdatedData, - AgentMessageDeltaData, - AppListUpdatedData, - AuthStatusChangeData, - CommandExecutionOutputDeltaData, - CommandExecutionTerminalInteractionData, - ConfigWarningData, - ContextCompactedData, - DeprecationNoticeData, - ErrorEventData, - FileChangeOutputDeltaData, - ItemCompletedData, - ItemStartedData, - LoginChatGptCompleteData, - McpServerOAuthLoginCompletedData, - McpToolCallProgressData, - ModelReroutedData, - PlanDeltaData, - RawResponseItemCompletedData, - ReasoningSummaryPartAddedData, - ReasoningSummaryTextDeltaData, - ReasoningTextDeltaData, - ServerRequestResolvedData, - SessionConfiguredData, - ThreadArchivedData, - ThreadCompactedData, - ThreadNameUpdatedData, - ThreadStartedData, - ThreadStatusChangedData, - ThreadTokenUsageUpdatedData, - ThreadUnarchivedData, - TurnCompletedData, - TurnDiffUpdatedData, - TurnErrorData, - TurnPlanUpdatedData, - TurnStartedData, - WindowsWorldWritableWarningData, -) - - -class ErrorEvent(CodexBaseModel): - """Error event from the Codex server.""" - - event_type: Literal["error"] = "error" - data: ErrorEventData - - -# ============================================================================ -# Thread lifecycle events -# ============================================================================ - - -class ThreadStartedEvent(CodexBaseModel): - """Thread started event.""" - - event_type: Literal["thread/started"] = "thread/started" - data: ThreadStartedData - - -class ThreadStatusChangedEvent(CodexBaseModel): - """Thread status changed event.""" - - event_type: Literal["thread/status/changed"] = "thread/status/changed" - data: ThreadStatusChangedData - - -class ThreadArchivedEvent(CodexBaseModel): - """Thread archived event.""" - - event_type: Literal["thread/archived"] = "thread/archived" - data: ThreadArchivedData - - -class ThreadUnarchivedEvent(CodexBaseModel): - """Thread unarchived event.""" - - event_type: Literal["thread/unarchived"] = "thread/unarchived" - data: ThreadUnarchivedData - - -class ThreadNameUpdatedEvent(CodexBaseModel): - """Thread name updated event.""" - - event_type: Literal["thread/name/updated"] = "thread/name/updated" - data: ThreadNameUpdatedData - - -class ThreadTokenUsageUpdatedEvent(CodexBaseModel): - """Thread token usage updated event.""" - - event_type: Literal["thread/tokenUsage/updated"] = "thread/tokenUsage/updated" - data: ThreadTokenUsageUpdatedData - - -class ThreadCompactedEvent(CodexBaseModel): - """Thread compacted event.""" - - event_type: Literal["thread/compacted"] = "thread/compacted" - data: ThreadCompactedData - - -# ============================================================================ -# Turn lifecycle events -# ============================================================================ - - -class TurnStartedEvent(CodexBaseModel): - """Turn started event.""" - - event_type: Literal["turn/started"] = "turn/started" - data: TurnStartedData - - -class TurnCompletedEvent(CodexBaseModel): - """Turn completed event.""" - - event_type: Literal["turn/completed"] = "turn/completed" - data: TurnCompletedData - - -class TurnErrorEvent(CodexBaseModel): - """Turn error event.""" - - event_type: Literal["turn/error"] = "turn/error" - data: TurnErrorData - - -class TurnDiffUpdatedEvent(CodexBaseModel): - """Turn diff updated event.""" - - event_type: Literal["turn/diff/updated"] = "turn/diff/updated" - data: TurnDiffUpdatedData - - -class TurnPlanUpdatedEvent(CodexBaseModel): - """Turn plan updated event.""" - - event_type: Literal["turn/plan/updated"] = "turn/plan/updated" - data: TurnPlanUpdatedData - - -# ============================================================================ -# Item lifecycle events -# ============================================================================ - - -class ItemStartedEvent(CodexBaseModel): - """Item started event.""" - - event_type: Literal["item/started"] = "item/started" - data: ItemStartedData - - -class ItemCompletedEvent(CodexBaseModel): - """Item completed event.""" - - event_type: Literal["item/completed"] = "item/completed" - data: ItemCompletedData - - -class RawResponseItemCompletedEvent(CodexBaseModel): - """Raw response item completed event.""" - - event_type: Literal["rawResponseItem/completed"] = "rawResponseItem/completed" - data: RawResponseItemCompletedData - - -# ============================================================================ -# Item delta events - Agent messages -# ============================================================================ - - -class AgentMessageDeltaEvent(CodexBaseModel): - """Agent message delta event (streaming text).""" - - event_type: Literal["item/agentMessage/delta"] = "item/agentMessage/delta" - data: AgentMessageDeltaData - - -# ============================================================================ -# Item delta events - Plan -# ============================================================================ - - -class PlanDeltaEvent(CodexBaseModel): - """Plan delta event (streaming plan text).""" - - event_type: Literal["item/plan/delta"] = "item/plan/delta" - data: PlanDeltaData - - -# ============================================================================ -# Item delta events - Reasoning -# ============================================================================ - - -class ReasoningSummaryTextDeltaEvent(CodexBaseModel): - """Reasoning summary text delta event.""" - - event_type: Literal["item/reasoning/summaryTextDelta"] = "item/reasoning/summaryTextDelta" - data: ReasoningSummaryTextDeltaData - - -class ReasoningSummaryPartAddedEvent(CodexBaseModel): - """Reasoning summary part added event.""" - - event_type: Literal["item/reasoning/summaryPartAdded"] = "item/reasoning/summaryPartAdded" - data: ReasoningSummaryPartAddedData - - -class ReasoningTextDeltaEvent(CodexBaseModel): - """Reasoning text delta event.""" - - event_type: Literal["item/reasoning/textDelta"] = "item/reasoning/textDelta" - data: ReasoningTextDeltaData - - -# ============================================================================ -# Item delta events - Command execution -# ============================================================================ - - -class CommandExecutionOutputDeltaEvent(CodexBaseModel): - """Command execution output delta event.""" - - event_type: Literal["item/commandExecution/outputDelta"] = "item/commandExecution/outputDelta" - data: CommandExecutionOutputDeltaData - - -class CommandExecutionTerminalInteractionEvent(CodexBaseModel): - """Command execution terminal interaction event.""" - - event_type: Literal["item/commandExecution/terminalInteraction"] = ( - "item/commandExecution/terminalInteraction" - ) - data: CommandExecutionTerminalInteractionData - - -# ============================================================================ -# Item delta events - File changes -# ============================================================================ - - -class FileChangeOutputDeltaEvent(CodexBaseModel): - """File change output delta event.""" - - event_type: Literal["item/fileChange/outputDelta"] = "item/fileChange/outputDelta" - data: FileChangeOutputDeltaData - - -# ============================================================================ -# Item delta events - MCP tool calls -# ============================================================================ - - -class McpToolCallProgressEvent(CodexBaseModel): - """MCP tool call progress event.""" - - event_type: Literal["item/mcpToolCall/progress"] = "item/mcpToolCall/progress" - data: McpToolCallProgressData - - -# ============================================================================ -# MCP OAuth events -# ============================================================================ - - -class McpServerOAuthLoginCompletedEvent(CodexBaseModel): - """MCP server OAuth login completed event.""" - - event_type: Literal["mcpServer/oauthLogin/completed"] = "mcpServer/oauthLogin/completed" - data: McpServerOAuthLoginCompletedData - - -# ============================================================================ -# Account/Auth events -# ============================================================================ - - -class AccountUpdatedEvent(CodexBaseModel): - """Account updated event.""" - - event_type: Literal["account/updated"] = "account/updated" - data: AccountUpdatedData - - -class AccountRateLimitsUpdatedEvent(CodexBaseModel): - """Account rate limits updated event.""" - - event_type: Literal["account/rateLimits/updated"] = "account/rateLimits/updated" - data: AccountRateLimitsUpdatedData - - -class AccountLoginCompletedEvent(CodexBaseModel): - """Account login completed event.""" - - event_type: Literal["account/login/completed"] = "account/login/completed" - data: AccountLoginCompletedData - - -class AuthStatusChangeEvent(CodexBaseModel): - """Auth status change event (legacy v1).""" - - event_type: Literal["authStatusChange"] = "authStatusChange" - data: AuthStatusChangeData - - -class LoginChatGptCompleteEvent(CodexBaseModel): - """Login ChatGPT complete event (legacy v1).""" - - event_type: Literal["loginChatGptComplete"] = "loginChatGptComplete" - data: LoginChatGptCompleteData - - -# ============================================================================ -# System events -# ============================================================================ - - -class SessionConfiguredEvent(CodexBaseModel): - """Session configured event.""" - - event_type: Literal["sessionConfigured"] = "sessionConfigured" - data: SessionConfiguredData - - -class DeprecationNoticeEvent(CodexBaseModel): - """Deprecation notice event.""" - - event_type: Literal["deprecationNotice"] = "deprecationNotice" - data: DeprecationNoticeData - - -class WindowsWorldWritableWarningEvent(CodexBaseModel): - """Windows world writable warning event.""" - - event_type: Literal["windows/worldWritableWarning"] = "windows/worldWritableWarning" - data: WindowsWorldWritableWarningData - - -# ============================================================================ -# New events -# ============================================================================ - - -class ModelReroutedEvent(CodexBaseModel): - """Model rerouted event.""" - - event_type: Literal["model/rerouted"] = "model/rerouted" - data: ModelReroutedData - - -class ConfigWarningEvent(CodexBaseModel): - """Config warning event.""" - - event_type: Literal["configWarning"] = "configWarning" - data: ConfigWarningData - - -class AppListUpdatedEvent(CodexBaseModel): - """App list updated event.""" - - event_type: Literal["app/list/updated"] = "app/list/updated" - data: AppListUpdatedData - - -class ContextCompactedEvent(CodexBaseModel): - """Context compacted event (alias for ThreadCompactedEvent with turnId).""" - - event_type: Literal["thread/compacted/v2"] = "thread/compacted/v2" - data: ContextCompactedData - - -class ServerRequestResolvedEvent(CodexBaseModel): - """Server request resolved event.""" - - event_type: Literal["serverRequest/resolved"] = "serverRequest/resolved" - data: ServerRequestResolvedData - - -# ============================================================================ -# Discriminated union of all event types -# ============================================================================ - - -CodexEvent = Annotated[ - # Error events - ErrorEvent - # Thread lifecycle - | ThreadStartedEvent - | ThreadStatusChangedEvent - | ThreadArchivedEvent - | ThreadUnarchivedEvent - | ThreadNameUpdatedEvent - | ThreadTokenUsageUpdatedEvent - | ThreadCompactedEvent - # Turn lifecycle - | TurnStartedEvent - | TurnCompletedEvent - | TurnErrorEvent - | TurnDiffUpdatedEvent - | TurnPlanUpdatedEvent - # Item lifecycle - | ItemStartedEvent - | ItemCompletedEvent - | RawResponseItemCompletedEvent - # Item deltas - agent messages - | AgentMessageDeltaEvent - # Item deltas - plan - | PlanDeltaEvent - # Item deltas - reasoning - | ReasoningSummaryTextDeltaEvent - | ReasoningSummaryPartAddedEvent - | ReasoningTextDeltaEvent - # Item deltas - command execution - | CommandExecutionOutputDeltaEvent - | CommandExecutionTerminalInteractionEvent - # Item deltas - file changes - | FileChangeOutputDeltaEvent - # Item deltas - MCP tool calls - | McpToolCallProgressEvent - # MCP OAuth - | McpServerOAuthLoginCompletedEvent - # Account/Auth events - | AccountUpdatedEvent - | AccountRateLimitsUpdatedEvent - | AccountLoginCompletedEvent - | AuthStatusChangeEvent - | LoginChatGptCompleteEvent - # System events - | SessionConfiguredEvent - | DeprecationNoticeEvent - | WindowsWorldWritableWarningEvent - # New events - | ModelReroutedEvent - | ConfigWarningEvent - | AppListUpdatedEvent - | ContextCompactedEvent - | ServerRequestResolvedEvent, - Field(discriminator="event_type"), -] - - -# TypeAdapter for parsing events -_codex_event_adapter: TypeAdapter[CodexEvent] = TypeAdapter(CodexEvent) - - -# ============================================================================ -# Event type literals (for external use) -# ============================================================================ - - -EventType = Literal[ - # Error events - "error", - # Thread lifecycle - "thread/started", - "thread/status/changed", - "thread/archived", - "thread/unarchived", - "thread/name/updated", - "thread/tokenUsage/updated", - "thread/compacted", - # Turn lifecycle - "turn/started", - "turn/completed", - "turn/error", - "turn/diff/updated", - "turn/plan/updated", - # Item lifecycle - "item/started", - "item/completed", - "rawResponseItem/completed", - # Item deltas - agent messages - "item/agentMessage/delta", - # Item deltas - plan - "item/plan/delta", - # Item deltas - reasoning - "item/reasoning/summaryTextDelta", - "item/reasoning/summaryPartAdded", - "item/reasoning/textDelta", - # Item deltas - command execution - "item/commandExecution/outputDelta", - "item/commandExecution/terminalInteraction", - # Item deltas - file changes - "item/fileChange/outputDelta", - # Item deltas - MCP tool calls - "item/mcpToolCall/progress", - # MCP OAuth - "mcpServer/oauthLogin/completed", - # Account/Auth events - "account/updated", - "account/rateLimits/updated", - "account/login/completed", - "authStatusChange", - "loginChatGptComplete", - # System events - "sessionConfigured", - "deprecationNotice", - "windows/worldWritableWarning", - # New events - "model/rerouted", - "configWarning", - "app/list/updated", - "thread/compacted/v2", - "serverRequest/resolved", -] - - -# ============================================================================ -# Factory function for creating events from JSON-RPC notifications -# ============================================================================ - - -def parse_codex_event(method: str, params: dict[str, Any] | None) -> CodexEvent | None: - """Create a CodexEvent from a JSON-RPC notification. - - Uses the TypeAdapter with discriminator for type-safe parsing of known events. - Returns None for legacy codex/event/* methods (duplicates of V2 events). - - Args: - method: The JSON-RPC notification method (event type) - params: The notification parameters (event data) - - Returns: - A typed CodexEvent instance, or None for legacy events to skip - - Raises: - ValueError: If the event type is unknown (add a new model for it) - """ - # Skip legacy V1 events - they duplicate V2 events in a different format - if method.startswith("codex/event/"): - return None - - event_data = {"event_type": method, "data": params or {}} - return _codex_event_adapter.validate_python(event_data) - - -# ============================================================================ -# Type-safe delta extraction -# ============================================================================ - - -# Type alias for all delta events -DeltaEvent = ( - AgentMessageDeltaEvent - | PlanDeltaEvent - | ReasoningTextDeltaEvent - | ReasoningSummaryTextDeltaEvent - | CommandExecutionOutputDeltaEvent - | FileChangeOutputDeltaEvent -) - - -def get_text_delta(event: CodexEvent) -> str: - """Extract text delta from a delta event. - - Type-safe extraction that only works on events with delta content. - - Args: - event: Any CodexEvent - - Returns: - The delta text if this is a delta event, empty string otherwise - """ - match event: - case ( - AgentMessageDeltaEvent(data=data) - | PlanDeltaEvent(data=data) - | ReasoningTextDeltaEvent(data=data) - | ReasoningSummaryTextDeltaEvent(data=data) - | CommandExecutionOutputDeltaEvent(data=data) - | FileChangeOutputDeltaEvent(data=data) - ): - return data.delta - case _: - return "" - - -def is_delta_event(event: CodexEvent) -> bool: - """Check if this is a delta event (streaming content).""" - return isinstance( - event, - AgentMessageDeltaEvent - | PlanDeltaEvent - | ReasoningTextDeltaEvent - | ReasoningSummaryTextDeltaEvent - | CommandExecutionOutputDeltaEvent - | FileChangeOutputDeltaEvent, - ) - - -def is_completed_event(event: CodexEvent) -> bool: - """Check if this is a completion event.""" - return isinstance( - event, - TurnCompletedEvent - | ItemCompletedEvent - | RawResponseItemCompletedEvent - | McpServerOAuthLoginCompletedEvent - | AccountLoginCompletedEvent - | LoginChatGptCompleteEvent, - ) - - -def is_error_event(event: CodexEvent) -> bool: - """Check if this is an error event.""" - return isinstance(event, ErrorEvent | TurnErrorEvent) diff --git a/src/codex_adapter/models/input_item.py b/src/codex_adapter/models/input_item.py deleted file mode 100644 index 99fc9f324..000000000 --- a/src/codex_adapter/models/input_item.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from typing import Literal, Self - -from codex_adapter.models.base import CodexBaseModel - - -class TextInputItem(CodexBaseModel): - """Text input for a turn.""" - - type: Literal["text"] = "text" - text: str - - -class LocalImageInputItem(CodexBaseModel): - """Local image file input for a turn.""" - - type: Literal["localImage"] = "localImage" - path: str - - -class ImageInputItem(CodexBaseModel): - """Image URL input for a turn.""" - - type: Literal["image"] = "image" - url: str - - @classmethod - def from_bytes(cls, data: bytes, media_type: str) -> Self: - import base64 - - b64 = base64.b64encode(data).decode() - data_uri = f"data:{media_type};base64,{b64}" - return cls(url=data_uri) - - -class SkillInputItem(CodexBaseModel): - """Skill input for a turn.""" - - type: Literal["skill"] = "skill" - name: str - path: str - - -class MentionInputItem(CodexBaseModel): - """Mention input for a turn.""" - - type: Literal["mention"] = "mention" - name: str - path: str - - -# Discriminated union of input types -TurnInputItem = ( - TextInputItem | LocalImageInputItem | ImageInputItem | SkillInputItem | MentionInputItem -) diff --git a/src/codex_adapter/models/mcp_server.py b/src/codex_adapter/models/mcp_server.py deleted file mode 100644 index db008bc44..000000000 --- a/src/codex_adapter/models/mcp_server.py +++ /dev/null @@ -1,26 +0,0 @@ -"""MCP server configuration models.""" - -from __future__ import annotations - -from pydantic import BaseModel - - -class StdioMcpServer(BaseModel): - """MCP server running as a subprocess via stdio transport.""" - - command: str - args: list[str] = [] - env: dict[str, str] | None = None - enabled: bool = True - - -class HttpMcpServer(BaseModel): - """MCP server accessible via HTTP/SSE transport.""" - - url: str - bearer_token_env_var: str | None = None - http_headers: dict[str, str] | None = None - enabled: bool = True - - -McpServerConfig = StdioMcpServer | HttpMcpServer diff --git a/src/codex_adapter/models/misc.py b/src/codex_adapter/models/misc.py deleted file mode 100644 index 8c2998f87..000000000 --- a/src/codex_adapter/models/misc.py +++ /dev/null @@ -1,519 +0,0 @@ -"""Pydantic models for Codex JSON-RPC API requests and responses.""" - -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import Field - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ( # noqa: TC001 - ExperimentalFeatureStage, - ExternalAgentConfigMigrationItemType, - InputModality, - McpAuthStatusValue, - MergeStrategy, - ModelProvider, - NetworkApprovalProtocol, - NetworkPolicyRuleAction, - PlanType, - ReasoningEffort, - SandboxMode, - SessionSource, - SkillApprovalDecision, - SkillScope, -) -from codex_adapter.models.thread_item import ThreadItem # noqa: TC001 -from codex_adapter.models.thread_status import ThreadStatusValue # noqa: TC001 - - -# Strict validation in tests to catch schema changes, lenient in production - -TurnStatusValue = Literal["completed", "interrupted", "failed", "inProgress"] -PlanStepStatus = Literal["pending", "inProgress", "completed"] - - -class TextPosition(CodexBaseModel): - """1-based text position.""" - - line: int - column: int - - -class TextRange(CodexBaseModel): - """Text range with start and end positions.""" - - start: TextPosition - end: TextPosition - - -class ClientInfo(CodexBaseModel): - """Client information for initialization.""" - - name: str - version: str - - -class ConfigEdit(CodexBaseModel): - """A single config edit operation.""" - - key_path: str - value: Any - merge_strategy: MergeStrategy - - -# ============================================================================ -# Server Request models (server -> client callbacks) -# ============================================================================ - - -class NetworkApprovalContext(CodexBaseModel): - """Network approval context for command approvals.""" - - host: str - protocol: NetworkApprovalProtocol - - -class NetworkPolicyAmendment(CodexBaseModel): - """Proposed network policy amendment.""" - - host: str - action: NetworkPolicyRuleAction - - -class ExecPolicyAmendment(CodexBaseModel): - """Proposed execpolicy amendment (prefix rule).""" - - command: list[str] - - -class ToolRequestUserInputOption(CodexBaseModel): - """A selectable option for a user input question.""" - - label: str - description: str - - -class ToolRequestUserInputQuestion(CodexBaseModel): - """A question in a tool request for user input.""" - - id: str - header: str - question: str - is_other: bool = False - is_secret: bool = False - options: list[ToolRequestUserInputOption] | None = None - - def to_schema_property(self) -> dict[str, Any]: - """Convert a Codex user input question to a JSON Schema property. - - Maps question options to enum values, and handles secret/free-text questions. - - Args: - question: Codex question with optional options list - - Returns: - JSON Schema property definition - """ - prop: dict[str, Any] = {"title": self.header or self.id} - if self.question: - prop["description"] = self.question - - if self.options and not self.is_other: - # Question with fixed options -> enum - prop["type"] = "string" - prop["enum"] = [opt.label for opt in self.options] - elif self.options and self.is_other: - # Options with an "other" free-text fallback -> enum + freeform - prop["type"] = "string" - prop["enum"] = [opt.label for opt in self.options] - else: - # Free-text question - prop["type"] = "string" - - if self.is_secret: - prop["writeOnly"] = True - - return prop - - -class ToolRequestUserInputAnswer(CodexBaseModel): - """A user's answer to a request_user_input question.""" - - answers: list[str] - - -class SkillRequestApprovalResponse(CodexBaseModel): - """Response for skill/requestApproval server request.""" - - decision: SkillApprovalDecision - - -class GitInfo(CodexBaseModel): - """Git metadata captured when thread was created.""" - - sha: str | None = None - branch: str | None = None - origin_url: str | None = None - - -class TurnStatus(CodexBaseModel): - """Turn status enumeration.""" - - # This is actually an enum in Rust but sent as string - status: TurnStatusValue - - -class TurnError(CodexBaseModel): - """Turn error information.""" - - message: str - codex_error_info: dict[str, Any] | str | None = ( - None # Error metadata - varied structure (dict or string like "other") - ) - additional_details: str | None = None - - -class Turn(CodexBaseModel): - """Turn data structure.""" - - id: str - items: list[ThreadItem] = Field(default_factory=list) - status: TurnStatusValue = "inProgress" - error: TurnError | None = None - - -class Thread(CodexBaseModel): - """Thread data structure.""" - - id: str - preview: str = "" - ephemeral: bool = False - model_provider: str = "openai" - created_at: int = 0 - updated_at: int = 0 - status: ThreadStatusValue | None = None - path: str | None = None - cwd: str = "" - cli_version: str = "" - source: SessionSource = "appServer" - agent_nickname: str | None = None - agent_role: str | None = None - git_info: GitInfo | None = None - name: str | None = None - turns: list[Turn] = Field(default_factory=list) - - -class ThreadData(CodexBaseModel): - """Thread data in responses.""" - - id: str - preview: str = "" - ephemeral: bool = False - model_provider: ModelProvider = "openai" - created_at: int = 0 - updated_at: int = 0 - status: ThreadStatusValue | None = None - path: str | None = None - cwd: str | None = None - cli_version: str | None = None - source: str | None = None - agent_nickname: str | None = None - agent_role: str | None = None - git_info: GitInfo | None = None - name: str | None = None - turns: list[Turn] = Field(default_factory=list) - - -class TurnData(CodexBaseModel): - """Turn data in responses.""" - - id: str - status: TurnStatusValue # always provided by the server - thread_id: str | None = None - items: list[ThreadItem] = Field(default_factory=list) - error: str | None = None - - -class SkillInterface(CodexBaseModel): - """Skill interface metadata.""" - - display_name: str | None = None - short_description: str | None = None - icon_small: str | None = None - icon_large: str | None = None - brand_color: str | None = None - default_prompt: str | None = None - - -class SkillToolDependency(CodexBaseModel): - """Skill tool dependency.""" - - type: str - value: str - description: str | None = None - transport: str | None = None - command: str | None = None - url: str | None = None - - -class SkillDependencies(CodexBaseModel): - """Skill dependencies.""" - - tools: list[SkillToolDependency] = Field(default_factory=list) - - -class SkillData(CodexBaseModel): - """A single skill definition (SkillMetadata in upstream).""" - - name: str - description: str | None = None - short_description: str | None = None - interface: SkillInterface | None = None - dependencies: SkillDependencies | None = None - path: str | None = None - scope: SkillScope | None = None - enabled: bool | None = None - - -class SkillErrorInfo(CodexBaseModel): - """Skill error information.""" - - path: str - message: str - - -class SkillsContainer(CodexBaseModel): - """Container for skills with cwd (SkillsListEntry in upstream).""" - - cwd: str - skills: list[SkillData] - errors: list[SkillErrorInfo] = Field(default_factory=list) - - -class ReasoningEffortOption(CodexBaseModel): - """A reasoning effort option with metadata.""" - - reasoning_effort: ReasoningEffort - description: str - - -class ModelUpgradeInfo(CodexBaseModel): - """Model upgrade information.""" - - model: str - upgrade_copy: str | None = None - model_link: str | None = None - migration_markdown: str | None = None - - -class ModelAvailabilityNux(CodexBaseModel): - """Model availability notification.""" - - message: str - - -class ModelData(CodexBaseModel): - """A single model definition.""" - - id: str - model: str - upgrade: str | None = None - upgrade_info: ModelUpgradeInfo | None = None - availability_nux: ModelAvailabilityNux | None = None - display_name: str - description: str - hidden: bool - is_default: bool - supported_reasoning_efforts: list[ReasoningEffortOption] - default_reasoning_effort: ReasoningEffort - input_modalities: list[InputModality] = Field( - default_factory=lambda: list[InputModality](["text", "image"]) - ) - supports_personality: bool = False - - -class McpTool(CodexBaseModel): - """Tool exposed by an MCP server.""" - - name: str - description: str | None = None - - -class McpResource(CodexBaseModel): - """Resource exposed by an MCP server.""" - - uri: str - name: str | None = None - description: str | None = None - mime_type: str | None = None - - -class McpResourceTemplate(CodexBaseModel): - """Resource template exposed by an MCP server.""" - - uri_template: str - name: str | None = None - description: str | None = None - mime_type: str | None = None - - -class McpServerStatusEntry(CodexBaseModel): - """Status of a single MCP server.""" - - name: str - tools: dict[str, McpTool] = Field(default_factory=dict) - resources: list[McpResource] = Field(default_factory=list) - resource_templates: list[McpResourceTemplate] = Field(default_factory=list) - auth_status: McpAuthStatusValue = "Unsupported" - - -# ============================================================================ -# Config models -# ============================================================================ - - -class ConfigLayerMetadata(CodexBaseModel): - """Config layer metadata.""" - - source: str - path: str | None = None - - -class ConfigLayer(CodexBaseModel): - """A single config layer.""" - - source: str - path: str | None = None - config: dict[str, Any] = Field(default_factory=dict) - - -class NetworkRequirements(CodexBaseModel): - """Network requirements configuration.""" - - enabled: bool | None = None - http_port: int | None = None - socks_port: int | None = None - allow_upstream_proxy: bool | None = None - dangerously_allow_non_loopback_proxy: bool | None = None - dangerously_allow_non_loopback_admin: bool | None = None - dangerously_allow_all_unix_sockets: bool | None = None - allowed_domains: list[str] | None = None - denied_domains: list[str] | None = None - allow_unix_sockets: list[str] | None = None - allow_local_binding: bool | None = None - - -class ConfigRequirements(CodexBaseModel): - """Configuration requirements (from requirements.toml / MDM).""" - - allowed_approval_policies: list[Any] | None = None # AskForApproval tagged union - allowed_sandbox_modes: list[SandboxMode] | None = None - allowed_web_search_modes: list[str] | None = None - enforce_residency: str | None = None - network: NetworkRequirements | None = None - - -class AppBranding(CodexBaseModel): - """App branding information.""" - - primary_color: str | None = None - icon: str | None = None - - -class AppReview(CodexBaseModel): - """App review status.""" - - status: str - - -class AppScreenshot(CodexBaseModel): - """App screenshot information.""" - - url: str | None = None - file_id: str | None = None - user_prompt: str - - -class AppMetadata(CodexBaseModel): - """App metadata information.""" - - review: AppReview | None = None - categories: list[str] | None = None - sub_categories: list[str] | None = None - seo_description: str | None = None - screenshots: list[AppScreenshot] | None = None - developer: str | None = None - version: str | None = None - version_id: str | None = None - version_notes: str | None = None - first_party_type: str | None = None - first_party_requires_install: bool | None = None - show_in_composer_when_unlinked: bool | None = None - - -class AppInfo(CodexBaseModel): - """App information.""" - - id: str - name: str - description: str | None = None - logo_url: str | None = None - logo_url_dark: str | None = None - distribution_channel: str | None = None - branding: AppBranding | None = None - app_metadata: AppMetadata | None = None - labels: dict[str, str] | None = None - install_url: str | None = None - is_accessible: bool = False - is_enabled: bool = True - - -class ExperimentalFeature(CodexBaseModel): - """An experimental feature.""" - - name: str - stage: ExperimentalFeatureStage - description: str | None = None - - -class ExternalAgentConfigMigrationItem(CodexBaseModel): - """External agent config migration item.""" - - item_type: ExternalAgentConfigMigrationItemType - description: str - cwd: str | None = None - - -class TurnPlanStep(CodexBaseModel): - """A single step in a turn plan.""" - - step: str - status: PlanStepStatus - - -class RateLimitWindow(CodexBaseModel): - """Rate limit window information.""" - - used_percent: int - window_duration_mins: int | None = None - resets_at: int | None = None - - -class CreditsSnapshot(CodexBaseModel): - """Credits snapshot information.""" - - has_credits: bool - unlimited: bool - balance: str | None = None - - -class RateLimitSnapshot(CodexBaseModel): - """Rate limit snapshot.""" - - limit_id: str | None = None - limit_name: str | None = None - primary: RateLimitWindow | None = None - secondary: RateLimitWindow | None = None - credits: CreditsSnapshot | None = None - plan_type: PlanType | None = None diff --git a/src/codex_adapter/models/request_params.py b/src/codex_adapter/models/request_params.py deleted file mode 100644 index af49f7815..000000000 --- a/src/codex_adapter/models/request_params.py +++ /dev/null @@ -1,408 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal, Self - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ( # noqa: TC001 - ApprovalPolicy, - CollaborationMode, - CommandExecutionApprovalDecision, - MergeStrategy, - Personality, - ReasoningEffort, - ReasoningSummary, - ReviewDelivery, - SandboxMode, - ThreadSortKey, - ThreadSourceKind, -) -from codex_adapter.models.command_action import CommandAction # noqa: TC001 -from codex_adapter.models.input_item import TurnInputItem # noqa: TC001 -from codex_adapter.models.misc import ( # noqa: TC001 - ClientInfo, - ConfigEdit, - ExecPolicyAmendment, - ExternalAgentConfigMigrationItem, - NetworkApprovalContext, - NetworkPolicyAmendment, - ToolRequestUserInputQuestion, -) - - -LoginType = Literal["apiKey", "chatgpt", "chatgptAuthTokens"] - - -class InitializeParams(CodexBaseModel): - """Parameters for initialize request.""" - - client_info: ClientInfo - - @classmethod - def create(cls, name: str, version: str) -> Self: - return cls(client_info=ClientInfo(name=name, version=version)) - - -class ThreadStartParams(CodexBaseModel): - """Parameters for thread/start request.""" - - cwd: str | None = None - model: str | None = None - model_provider: str | None = None - base_instructions: str | None = None - developer_instructions: str | None = None - approval_policy: ApprovalPolicy | None = None - sandbox: SandboxMode | None = None - config: dict[str, Any] | None = None - service_name: str | None = None - personality: Personality | None = None - ephemeral: bool | None = None - experimental_raw_events: bool = False - persist_extended_history: bool = False - - -class ThreadResumeParams(CodexBaseModel): - """Parameters for thread/resume request.""" - - thread_id: str - history: list[dict[str, Any]] | None = None - path: str | None = None - cwd: str | None = None - model: str | None = None - model_provider: str | None = None - base_instructions: str | None = None - developer_instructions: str | None = None - approval_policy: ApprovalPolicy | None = None - sandbox: SandboxMode | None = None - config: dict[str, Any] | None = None - personality: Personality | None = None - persist_extended_history: bool = False - - -class ThreadForkParams(CodexBaseModel): - """Parameters for thread/fork request.""" - - thread_id: str - path: str | None = None - cwd: str | None = None - model: str | None = None - model_provider: str | None = None - base_instructions: str | None = None - developer_instructions: str | None = None - approval_policy: ApprovalPolicy | None = None - sandbox: SandboxMode | None = None - config: dict[str, Any] | None = None - personality: Personality | None = None - persist_extended_history: bool = False - - -class ThreadListParams(CodexBaseModel): - """Parameters for thread/list request.""" - - cursor: str | None = None - limit: int | None = None - sort_key: ThreadSortKey | None = None - model_providers: list[str] | None = None - source_kinds: list[ThreadSourceKind] | None = None - archived: bool | None = None - cwd: str | None = None - search_term: str | None = None - - -class ThreadReadParams(CodexBaseModel): - """Parameters for thread/read request.""" - - thread_id: str - include_turns: bool = False - - -class ThreadArchiveParams(CodexBaseModel): - """Parameters for thread/archive request.""" - - thread_id: str - - -class ThreadUnarchiveParams(CodexBaseModel): - """Parameters for thread/unarchive request.""" - - thread_id: str - - -class ThreadSetNameParams(CodexBaseModel): - """Parameters for thread/name/set request.""" - - thread_id: str - name: str - - -class ThreadCompactStartParams(CodexBaseModel): - """Parameters for thread/compact/start request.""" - - thread_id: str - - -class ThreadRollbackParams(CodexBaseModel): - """Parameters for thread/rollback request.""" - - thread_id: str - turns: int - - -class ThreadUnsubscribeParams(CodexBaseModel): - """Parameters for thread/unsubscribe request.""" - - thread_id: str - - -class ThreadLoadedListParams(CodexBaseModel): - """Parameters for thread/loaded/list request.""" - - -class TurnStartParams(CodexBaseModel): - """Parameters for turn/start request.""" - - thread_id: str - input: list[TurnInputItem] - model: str | None = None - effort: ReasoningEffort | None = None - approval_policy: ApprovalPolicy | None = None - cwd: str | None = None - sandbox_policy: dict[str, Any] | None = None # Sandbox config - flexible structure - summary: ReasoningSummary | None = None - output_schema: dict[str, Any] | None = None # JSON Schema - arbitrary structure - personality: Personality | None = None - collaboration_mode: CollaborationMode | None = None - - -class TurnSteerParams(CodexBaseModel): - """Parameters for turn/steer request.""" - - thread_id: str - input: list[TurnInputItem] - expected_turn_id: str - - -class TurnInterruptParams(CodexBaseModel): - """Parameters for turn/interrupt request.""" - - thread_id: str - turn_id: str - - -class ReviewStartParams(CodexBaseModel): - """Parameters for review/start request.""" - - thread_id: str - target: dict[str, Any] # ReviewTarget - discriminated union - delivery: ReviewDelivery | None = None - - -class SkillsListParams(CodexBaseModel): - """Parameters for skills/list request.""" - - cwds: list[str] | None = None - force_reload: bool | None = None - per_cwd_extra_user_roots: list[dict[str, Any]] | None = None - - -class SkillsConfigWriteParams(CodexBaseModel): - """Parameters for skills/config/write request.""" - - path: str - enabled: bool - - -HazelnutScope = Literal["example", "workspace-shared", "all-shared", "personal"] -ProductSurface = Literal["chatgpt", "codex", "api", "atlas"] - - -class SkillsRemoteListParams(CodexBaseModel): - """Parameters for skills/remote/list request.""" - - hazelnut_scope: HazelnutScope = "example" - product_surface: ProductSurface = "codex" - enabled: bool = False - - -class SkillsRemoteExportParams(CodexBaseModel): - """Parameters for skills/remote/export request.""" - - hazelnut_id: str - - -class CollaborationModeListParams(CodexBaseModel): - """Parameters for collaborationMode/list request.""" - - -class CommandExecParams(CodexBaseModel): - """Parameters for command/exec request.""" - - command: list[str] - cwd: str | None = None - sandbox_policy: dict[str, Any] | None = None # Sandbox config - flexible structure - timeout_ms: int | None = None - - -class ModelListParams(CodexBaseModel): - """Parameters for model/list request.""" - - cursor: str | None = None - limit: int | None = None - include_hidden: bool | None = None - - -class McpServerOauthLoginParams(CodexBaseModel): - """Parameters for mcpServer/oauth/login request.""" - - name: str - scopes: list[str] | None = None - timeout_secs: int | None = None - - -class ListMcpServerStatusParams(CodexBaseModel): - """Parameters for mcpServerStatus/list request.""" - - cursor: str | None = None - limit: int | None = None - - -class AppsListParams(CodexBaseModel): - """Parameters for app/list request.""" - - cursor: str | None = None - limit: int | None = None - thread_id: str | None = None - force_refetch: bool | None = None - - -class ExperimentalFeatureListParams(CodexBaseModel): - """Parameters for experimentalFeature/list request.""" - - cursor: str | None = None - limit: int | None = None - - -class FeedbackUploadParams(CodexBaseModel): - """Parameters for feedback/upload request.""" - - classification: str - reason: str | None = None - thread_id: str | None = None - include_logs: bool = False - extra_log_files: list[str] | None = None - - -class ConfigReadParams(CodexBaseModel): - """Parameters for config/read request.""" - - include_layers: bool - cwd: str | None = None - - -class ConfigValueWriteParams(CodexBaseModel): - """Parameters for config/value/write request.""" - - key_path: str - value: Any - merge_strategy: MergeStrategy - file_path: str | None = None - expected_version: str | None = None - - -class ConfigBatchWriteParams(CodexBaseModel): - """Parameters for config/batchWrite request.""" - - edits: list[ConfigEdit] - file_path: str | None = None - expected_version: str | None = None - - -class GetAccountParams(CodexBaseModel): - """Parameters for account/read request.""" - - refresh_token: bool - - -class LoginAccountParams(CodexBaseModel): - """Parameters for account/login/start request. - - This is a discriminated union - use type field. - """ - - type: LoginType - api_key: str | None = None - access_token: str | None = None - chatgpt_account_id: str | None = None - chatgpt_plan_type: str | None = None - - -class CancelLoginAccountParams(CodexBaseModel): - """Parameters for account/login/cancel request.""" - - login_id: str - - -class ExternalAgentConfigDetectParams(CodexBaseModel): - """Parameters for externalAgentConfig/detect request.""" - - include_home: bool | None = None - cwds: list[str] | None = None - - -class ExternalAgentConfigImportParams(CodexBaseModel): - """Parameters for externalAgentConfig/import request.""" - - migration_items: list[ExternalAgentConfigMigrationItem] - - -class CommandExecutionRequestApprovalParams(CodexBaseModel): - """Parameters for item/commandExecution/requestApproval server request.""" - - thread_id: str - turn_id: str - item_id: str - approval_id: str | None = None - reason: str | None = None - network_approval_context: NetworkApprovalContext | None = None - command: str | None = None - cwd: str | None = None - command_actions: list[CommandAction] | None = None - additional_permissions: dict[str, Any] | None = None - proposed_execpolicy_amendment: ExecPolicyAmendment | None = None - proposed_network_policy_amendments: list[NetworkPolicyAmendment] | None = None - available_decisions: list[CommandExecutionApprovalDecision] | None = None - - -class FileChangeRequestApprovalParams(CodexBaseModel): - """Parameters for item/fileChange/requestApproval server request.""" - - thread_id: str - turn_id: str - item_id: str - reason: str | None = None - grant_root: str | None = None - - -class ToolRequestUserInputParams(CodexBaseModel): - """Parameters for item/tool/requestUserInput server request.""" - - thread_id: str - turn_id: str - item_id: str - questions: list[ToolRequestUserInputQuestion] - - -class SkillRequestApprovalParams(CodexBaseModel): - """Parameters for skill/requestApproval server request.""" - - item_id: str - skill_name: str - - -class DynamicToolCallParams(CodexBaseModel): - """Parameters for item/tool/call server request.""" - - thread_id: str - turn_id: str - call_id: str - tool: str - arguments: Any diff --git a/src/codex_adapter/models/responses.py b/src/codex_adapter/models/responses.py deleted file mode 100644 index fc27f2ef3..000000000 --- a/src/codex_adapter/models/responses.py +++ /dev/null @@ -1,290 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ( # noqa: TC001 - AskForApproval, - CommandExecutionApprovalDecision, - FileChangeApprovalDecision, - ModeKind, - ReasoningEffort, - SandboxPolicy, - WriteStatus, -) -from codex_adapter.models.misc import ( # noqa: TC001 - AppInfo, - ConfigLayer, - ConfigLayerMetadata, - ConfigRequirements, - ExperimentalFeature, - ExternalAgentConfigMigrationItem, - McpServerStatusEntry, - ModelData, - SkillsContainer, - ThreadData, - ToolRequestUserInputAnswer, - Turn, - TurnData, -) -from codex_adapter.models.thread_item import DynamicToolCallOutputContentItem # noqa: TC001 - - -class CommandExecutionRequestApprovalResponse(CodexBaseModel): - """Response for item/commandExecution/requestApproval server request.""" - - decision: CommandExecutionApprovalDecision - - -class FileChangeRequestApprovalResponse(CodexBaseModel): - """Response for item/fileChange/requestApproval server request.""" - - decision: FileChangeApprovalDecision - - -class ToolRequestUserInputResponse(CodexBaseModel): - """Response for item/tool/requestUserInput server request.""" - - answers: dict[str, ToolRequestUserInputAnswer] - - -class DynamicToolCallResponse(CodexBaseModel): - """Response for item/tool/call server request.""" - - content_items: list[DynamicToolCallOutputContentItem] - success: bool - - -class ThreadReadResponse(CodexBaseModel): - """Response for thread/read request.""" - - thread: ThreadData - - -class ThreadResponse(CodexBaseModel): - """Response for thread/start, thread/resume, and thread/fork.""" - - thread: ThreadData - model: str - model_provider: str - cwd: str - approval_policy: AskForApproval - sandbox: SandboxPolicy - reasoning_effort: ReasoningEffort | None = None - - -class TurnStartResponse(CodexBaseModel): - """Response for turn/start request.""" - - turn: TurnData - - -class TurnSteerResponse(CodexBaseModel): - """Response for turn/steer request.""" - - turn_id: str - - -class ReviewStartResponse(CodexBaseModel): - """Response for review/start request.""" - - turn: TurnData - review_thread_id: str - - -class ThreadListResponse(CodexBaseModel): - """Response for thread/list request.""" - - data: list[ThreadData] - next_cursor: str | None = None - - -class ThreadLoadedListResponse(CodexBaseModel): - """Response for thread/loaded/list request.""" - - data: list[str] - - -class ThreadRollbackResponse(CodexBaseModel): - """Response for thread/rollback request.""" - - thread: ThreadData - turns: list[Turn] - - -class ThreadUnarchiveResponse(CodexBaseModel): - """Response for thread/unarchive request.""" - - thread: ThreadData - - -class SkillsListResponse(CodexBaseModel): - """Response for skills/list request.""" - - data: list[SkillsContainer] - - -class SkillsConfigWriteResponse(CodexBaseModel): - """Response for skills/config/write request.""" - - -class RemoteSkillSummary(CodexBaseModel): - """Summary of a remote skill.""" - - id: str - name: str - description: str - - -class SkillsRemoteListResponse(CodexBaseModel): - """Response for skills/remote/list request.""" - - data: list[RemoteSkillSummary] - - -class SkillsRemoteExportResponse(CodexBaseModel): - """Response for skills/remote/export request.""" - - id: str - path: str - - -class ModelListResponse(CodexBaseModel): - """Response for model/list request.""" - - data: list[ModelData] - next_cursor: str | None = None - - -class CommandExecResponse(CodexBaseModel): - """Response for command/exec request.""" - - exit_code: int - stdout: str = "" - stderr: str = "" - - -class ListMcpServerStatusResponse(CodexBaseModel): - """Response for mcpServerStatus/list request.""" - - data: list[McpServerStatusEntry] - next_cursor: str | None = None - - -class McpServerOauthLoginResponse(CodexBaseModel): - """Response for mcpServer/oauth/login request.""" - - authorization_url: str - - -class McpServerRefreshResponse(CodexBaseModel): - """Response for config/mcpServer/reload request.""" - - -# ============================================================================ -# Account models -# ============================================================================ - - -class GetAccountResponse(CodexBaseModel): - """Response for account/read request.""" - - account: dict[str, Any] | None = None # Account enum - flexible - requires_openai_auth: bool = False - - -class LoginAccountResponse(CodexBaseModel): - """Response for account/login/start request.""" - - type: Literal["apiKey", "chatgpt", "chatgptAuthTokens"] - login_id: str | None = None - auth_url: str | None = None - - -CancelLoginAccountStatus = Literal["canceled", "notFound"] - - -class CancelLoginAccountResponse(CodexBaseModel): - """Response for account/login/cancel request.""" - - status: CancelLoginAccountStatus - - -class GetAccountRateLimitsResponse(CodexBaseModel): - """Response for account/rateLimits/read request.""" - - rate_limits: dict[str, Any] # RateLimitSnapshot - flexible - rate_limits_by_limit_id: dict[str, Any] | None = None - - -class ConfigReadResponse(CodexBaseModel): - """Response for config/read request.""" - - config: dict[str, Any] - origins: dict[str, ConfigLayerMetadata] | None = None - layers: list[ConfigLayer] | None = None - - -class ConfigWriteResponse(CodexBaseModel): - """Response for config/value/write and config/batchWrite requests.""" - - status: WriteStatus - version: str - file_path: str - overridden_metadata: dict[str, Any] | None = None - - -class ConfigRequirementsReadResponse(CodexBaseModel): - """Response for configRequirements/read request.""" - - requirements: ConfigRequirements | None = None - - -class AppsListResponse(CodexBaseModel): - """Response for app/list request.""" - - data: list[AppInfo] - next_cursor: str | None = None - - -class ExperimentalFeatureListResponse(CodexBaseModel): - """Response for experimentalFeature/list request.""" - - data: list[ExperimentalFeature] - next_cursor: str | None = None - - -class FeedbackUploadResponse(CodexBaseModel): - """Response for feedback/upload request.""" - - thread_id: str - - -ThreadUnsubscribeStatus = Literal["notLoaded", "notSubscribed", "unsubscribed"] - - -class ThreadUnsubscribeResponse(CodexBaseModel): - """Response for thread/unsubscribe request.""" - - status: ThreadUnsubscribeStatus - - -class CollaborationModeMask(CodexBaseModel): - """Collaboration mode preset metadata.""" - - name: str - mode: ModeKind | None = None - model: str | None = None - reasoning_effort: ReasoningEffort | None = None - - -class CollaborationModeListResponse(CodexBaseModel): - """Response for collaborationMode/list request.""" - - data: list[CollaborationModeMask] - - -class ExternalAgentConfigDetectResponse(CodexBaseModel): - """Response for externalAgentConfig/detect request.""" - - items: list[ExternalAgentConfigMigrationItem] diff --git a/src/codex_adapter/models/thread_item.py b/src/codex_adapter/models/thread_item.py deleted file mode 100644 index e4d43f12f..000000000 --- a/src/codex_adapter/models/thread_item.py +++ /dev/null @@ -1,228 +0,0 @@ -from __future__ import annotations - -from typing import Any, Literal - -from mcp.types import ContentBlock # noqa: TC002 -from pydantic import Field - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ( # noqa: TC001 - CollabAgentStatus, - CollabAgentTool, - CollabAgentToolCallStatus, - CommandExecutionStatus, - DynamicToolCallStatus, - McpToolCallStatus, - MessagePhase, - PatchApplyStatus, -) -from codex_adapter.models.command_action import CommandAction # noqa: TC001 -from codex_adapter.models.user_input import UserInput # noqa: TC001 -from codex_adapter.models.web_search import WebSearchAction # noqa: TC001 - - -# --------------------------------------------------------------------------- -# Types shared with misc.py — defined here to avoid circular imports. -# misc.py re-imports these from this module. -# --------------------------------------------------------------------------- - - -class DynamicToolCallOutputTextItem(CodexBaseModel): - """Text output content item for dynamic tool call response.""" - - type: Literal["inputText"] = "inputText" - text: str - - -class DynamicToolCallOutputImageItem(CodexBaseModel): - """Image output content item for dynamic tool call response.""" - - type: Literal["inputImage"] = "inputImage" - image_url: str - - -DynamicToolCallOutputContentItem = DynamicToolCallOutputTextItem | DynamicToolCallOutputImageItem - - -class PatchChangeKind(CodexBaseModel): - """Kind of file change (nested object in Codex's fileChange item).""" - - kind: Literal["add", "delete", "update"] = Field(validation_alias="type") - move_path: str | None = None - - -class FileUpdateChange(CodexBaseModel): - """File update change.""" - - path: str - kind: PatchChangeKind - diff: str | None = None # May be absent in "inProgress" state - - -class McpToolCallResult(CodexBaseModel): - """MCP tool call result.""" - - content: list[ContentBlock] - structured_content: Any = None - - -class McpToolCallError(CodexBaseModel): - """MCP tool call error.""" - - message: str - - -class CollabAgentState(CodexBaseModel): - """Collab agent state.""" - - status: CollabAgentStatus - message: str | None = None - - -class BaseThreadItem(CodexBaseModel): - """Base class for thread items.""" - - id: str - - -class ThreadItemUserMessage(BaseThreadItem): - """User message item.""" - - type: Literal["userMessage"] = "userMessage" - content: list[UserInput] - - -class ThreadItemAgentMessage(BaseThreadItem): - """Agent message item.""" - - type: Literal["agentMessage"] = "agentMessage" - text: str - phase: MessagePhase | None = None - - -class ThreadItemPlan(BaseThreadItem): - """Plan item.""" - - type: Literal["plan"] = "plan" - text: str - - -class ThreadItemReasoning(BaseThreadItem): - """Reasoning item.""" - - type: Literal["reasoning"] = "reasoning" - summary: list[str] = Field(default_factory=list) - content: list[str] = Field(default_factory=list) - - -class ThreadItemCommandExecution(BaseThreadItem): - """Command execution item.""" - - type: Literal["commandExecution"] = "commandExecution" - command: str - cwd: str - process_id: str | None = None - status: CommandExecutionStatus - command_actions: list[CommandAction] = Field(default_factory=list) - aggregated_output: str | None = None - exit_code: int | None = None - duration_ms: int | None = None - - -class ThreadItemFileChange(BaseThreadItem): - """File change item.""" - - type: Literal["fileChange"] = "fileChange" - changes: list[FileUpdateChange] - status: PatchApplyStatus - - -class ThreadItemMcpToolCall(BaseThreadItem): - """MCP tool call item.""" - - type: Literal["mcpToolCall"] = "mcpToolCall" - server: str - tool: str - status: McpToolCallStatus - arguments: dict[str, Any] | None = None - result: McpToolCallResult | None = None - error: McpToolCallError | None = None - duration_ms: int | None = None - - -class ThreadItemDynamicToolCall(BaseThreadItem): - """Dynamic tool call item.""" - - type: Literal["dynamicToolCall"] = "dynamicToolCall" - tool: str - arguments: dict[str, Any] | None = None - status: DynamicToolCallStatus - content_items: list[DynamicToolCallOutputContentItem] | None = None - success: bool | None = None - duration_ms: int | None = None - - -class ThreadItemWebSearch(BaseThreadItem): - """Web search item.""" - - type: Literal["webSearch"] = "webSearch" - query: str - action: WebSearchAction | None = None - - -class ThreadItemImageView(BaseThreadItem): - """Image view item.""" - - type: Literal["imageView"] = "imageView" - path: str - - -class ThreadItemEnteredReviewMode(BaseThreadItem): - """Entered review mode item.""" - - type: Literal["enteredReviewMode"] = "enteredReviewMode" - review: str - - -class ThreadItemExitedReviewMode(BaseThreadItem): - """Exited review mode item.""" - - type: Literal["exitedReviewMode"] = "exitedReviewMode" - review: str - - -class ThreadItemContextCompaction(BaseThreadItem): - """Context compaction item.""" - - type: Literal["contextCompaction"] = "contextCompaction" - - -class ThreadItemCollabAgentToolCall(BaseThreadItem): - """Collab agent tool call item.""" - - type: Literal["collabAgentToolCall"] = "collabAgentToolCall" - tool: CollabAgentTool - status: CollabAgentToolCallStatus - sender_thread_id: str - receiver_thread_ids: list[str] = Field(default_factory=list) - prompt: str | None = None - agents_states: dict[str, CollabAgentState] = Field(default_factory=dict) - - -# Discriminated union of all ThreadItem types -ThreadItem = ( - ThreadItemUserMessage - | ThreadItemAgentMessage - | ThreadItemPlan - | ThreadItemReasoning - | ThreadItemCommandExecution - | ThreadItemFileChange - | ThreadItemMcpToolCall - | ThreadItemDynamicToolCall - | ThreadItemCollabAgentToolCall - | ThreadItemWebSearch - | ThreadItemImageView - | ThreadItemEnteredReviewMode - | ThreadItemExitedReviewMode - | ThreadItemContextCompaction -) diff --git a/src/codex_adapter/models/thread_status.py b/src/codex_adapter/models/thread_status.py deleted file mode 100644 index fdc7e9a60..000000000 --- a/src/codex_adapter/models/thread_status.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from pydantic import Field - -from codex_adapter.models.base import CodexBaseModel -from codex_adapter.models.codex_types import ThreadActiveFlag # noqa: TC001 - - -class ThreadStatusNotLoaded(CodexBaseModel): - """Thread status: not loaded.""" - - type: Literal["notLoaded"] = "notLoaded" - - -class ThreadStatusIdle(CodexBaseModel): - """Thread status: idle.""" - - type: Literal["idle"] = "idle" - - -class ThreadStatusSystemError(CodexBaseModel): - """Thread status: system error.""" - - type: Literal["systemError"] = "systemError" - - -class ThreadStatusActive(CodexBaseModel): - """Thread status: active.""" - - type: Literal["active"] = "active" - active_flags: list[ThreadActiveFlag] = Field(default_factory=list) - - -ThreadStatusValue = ( - ThreadStatusNotLoaded | ThreadStatusIdle | ThreadStatusSystemError | ThreadStatusActive -) diff --git a/src/codex_adapter/models/token_usage.py b/src/codex_adapter/models/token_usage.py deleted file mode 100644 index fc928f3a3..000000000 --- a/src/codex_adapter/models/token_usage.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Token usage models for Codex.""" - -from __future__ import annotations - -from codex_adapter.models.base import CodexBaseModel - - -class TokenUsageBreakdown(CodexBaseModel): - """Token usage breakdown.""" - - total_tokens: int - input_tokens: int - cached_input_tokens: int - output_tokens: int - reasoning_output_tokens: int = 0 - - -class ThreadTokenUsage(CodexBaseModel): - """Thread token usage information.""" - - total: TokenUsageBreakdown - last: TokenUsageBreakdown - model_context_window: int | None = None - - -class Usage(CodexBaseModel): - """Simple token usage (legacy).""" - - input_tokens: int - cached_input_tokens: int - output_tokens: int diff --git a/src/codex_adapter/models/user_input.py b/src/codex_adapter/models/user_input.py deleted file mode 100644 index 1ea7c3efe..000000000 --- a/src/codex_adapter/models/user_input.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from pydantic import Field - -from codex_adapter.models.base import CodexBaseModel - - -class ByteRange(CodexBaseModel): - """Byte range within a UTF-8 text buffer. - - start: Start byte offset (inclusive). - end: End byte offset (exclusive). - """ - - start: int = Field(..., ge=0) - end: int = Field(..., ge=0) - - -class TextElement(CodexBaseModel): - """Element within text content for rich input markers. - - Used to render or persist rich input markers (e.g., image placeholders) - across history and resume without mutating the literal text. - """ - - byte_range: ByteRange - placeholder: str | None = None - - -class UserInputText(CodexBaseModel): - """Text user input.""" - - type: Literal["text"] = "text" - text: str - text_elements: list[TextElement] = Field(default_factory=list) - - -class UserInputImage(CodexBaseModel): - """Image URL user input.""" - - type: Literal["image"] = "image" - url: str - - -class UserInputLocalImage(CodexBaseModel): - """Local image file user input.""" - - type: Literal["local_image"] = "local_image" - path: str - - -class UserInputSkill(CodexBaseModel): - """Skill file user input.""" - - type: Literal["skill"] = "skill" - name: str - path: str - - -class UserInputMention(CodexBaseModel): - """Mention user input.""" - - type: Literal["mention"] = "mention" - name: str - path: str - - -# Discriminated union of user input types -UserInput = UserInputText | UserInputImage | UserInputLocalImage | UserInputSkill | UserInputMention diff --git a/src/codex_adapter/models/web_search.py b/src/codex_adapter/models/web_search.py deleted file mode 100644 index 323434c06..000000000 --- a/src/codex_adapter/models/web_search.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from codex_adapter.models.base import CodexBaseModel - - -class WebSearchActionSearch(CodexBaseModel): - """Web search action - search.""" - - type: Literal["search"] = "search" - query: str | None = None - queries: list[str] | None = None - - -class WebSearchActionOpenPage(CodexBaseModel): - """Web search action - open page.""" - - type: Literal["openPage"] = "openPage" - url: str | None = None - - -class WebSearchActionFindInPage(CodexBaseModel): - """Web search action - find in page.""" - - type: Literal["findInPage"] = "findInPage" - url: str | None = None - pattern: str | None = None - - -class WebSearchActionOther(CodexBaseModel): - """Web search action - other.""" - - type: Literal["other"] = "other" - - -WebSearchAction = ( - WebSearchActionSearch - | WebSearchActionOpenPage - | WebSearchActionFindInPage - | WebSearchActionOther -) diff --git a/src/opencode_sdk/__init__.py b/src/opencode_sdk/__init__.py new file mode 100644 index 000000000..06b43b14a --- /dev/null +++ b/src/opencode_sdk/__init__.py @@ -0,0 +1 @@ +"""Opencode SDK.""" diff --git a/src/opencode_sdk/client.py b/src/opencode_sdk/client.py new file mode 100644 index 000000000..34307a427 --- /dev/null +++ b/src/opencode_sdk/client.py @@ -0,0 +1,667 @@ +"""Async HTTP client for the OpenCode server API. + +Provides typed access to all OpenCode REST and SSE endpoints, returning +OpenCode SDK models directly. + +Usage: + async with OpenCodeClient("http://localhost:3000") as client: + session = await client.create_session() + await session.send_message(request) + messages = await session.list_messages() + + async for event in client.events(): + print(event) +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, Self + +import anyenv +import httpx +from pydantic import TypeAdapter + +from opencode_sdk.models.app import App, HealthResponse, PathInfo, Project, VcsInfo +from opencode_sdk.models.common import FileDiff +from opencode_sdk.models.config import Config +from opencode_sdk.models.events import Event, PermissionAskedProperties +from opencode_sdk.models.mcp import MCPStatus +from opencode_sdk.models.message import MessageWithParts +from opencode_sdk.models.question import QuestionRequest +from opencode_sdk.models.session import Session, SessionStatus, Todo + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from opencode_sdk.models.agent import Agent, Command, SkillInfo + from opencode_sdk.models.events import PermissionReplyRequest + from opencode_sdk.models.mcp import AddMcpServerRequest, LogLevel + from opencode_sdk.models.message import CommandRequest, MessageRequest, ShellRequest + from opencode_sdk.models.question import QuestionReply + from opencode_sdk.models.session import ( + SessionCreateRequest, + SessionForkRequest, + SessionInitRequest, + SessionUpdateRequest, + SummarizeRequest, + ) + + +_event_adapter: TypeAdapter[Event] = TypeAdapter(Event) + + +# ── Session handle ──────────────────────────────────────────────────── + + +class SessionHandle: + """Handle for interacting with a specific OpenCode session. + + Wraps session data + client reference so all session-scoped operations + can be called directly without passing session_id everywhere. + + Usage: + session = await client.create_session() + await session.send_message(request) + messages = await session.list_messages() + await session.abort() + """ + + def __init__(self, client: OpenCodeClient, info: Session) -> None: + self._client = client + self.info = info + + @property + def id(self) -> str: + return self.info.id + + @property + def title(self) -> str: + return self.info.title + + # ── Session lifecycle ───────────────────────────────────────── + + async def refresh(self) -> None: + """Re-fetch session data from the server.""" + handle = await self._client.session(self.id) + self.info = handle.info + + async def update(self, request: SessionUpdateRequest) -> None: + """Update session metadata (e.g. title, archive).""" + self.info = await self._client.update_session(self.id, request) + + async def delete(self) -> bool: + """Delete this session.""" + return await self._client.delete_session(self.id) + + async def abort(self) -> bool: + """Abort this session if busy.""" + return await self._client.abort_session(self.id) + + async def fork(self, request: SessionForkRequest | None = None) -> SessionHandle: + """Fork this session, returning a new SessionHandle.""" + return await self._client.fork_session(self.id, request) + + async def init(self, request: SessionInitRequest | None = None) -> MessageWithParts: + """Initialize this session (create AGENTS.md).""" + return await self._client.init_session(self.id, request) + + async def children(self) -> list[SessionHandle]: + """Get child sessions as handles.""" + sessions = await self._client.get_session_children(self.id) + return [SessionHandle(self._client, s) for s in sessions] + + # ── Messages ────────────────────────────────────────────────── + + async def list_messages(self, *, limit: int | None = None) -> list[MessageWithParts]: + """List messages in this session.""" + return await self._client.list_messages(self.id, limit=limit) + + async def send_message(self, request: MessageRequest) -> MessageWithParts: + """Send a message and wait for the agent's response.""" + return await self._client.send_message(self.id, request) + + async def send_message_async(self, request: MessageRequest) -> None: + """Send a message asynchronously (listen to SSE for updates).""" + return await self._client.send_message_async(self.id, request) + + async def get_message(self, message_id: str) -> MessageWithParts: + """Get a specific message.""" + return await self._client.get_message(self.id, message_id) + + async def delete_message(self, message_id: str) -> bool: + """Delete a message and all its parts.""" + return await self._client.delete_message(self.id, message_id) + + # ── Commands / Shell ────────────────────────────────────────── + + async def execute_command(self, request: CommandRequest) -> MessageWithParts: + """Execute a slash command in this session.""" + return await self._client.execute_command(self.id, request) + + async def shell(self, request: ShellRequest) -> MessageWithParts: + """Run a shell command in this session.""" + return await self._client.shell(self.id, request) + + async def summarize(self, request: SummarizeRequest | None = None) -> MessageWithParts: + """Summarize/compact this session.""" + return await self._client.summarize(self.id, request) + + # ── Diffs / Todos ───────────────────────────────────────────── + + async def todos(self) -> list[Todo]: + """Get todos for this session.""" + return await self._client.get_session_todos(self.id) + + async def diff(self) -> list[FileDiff]: + """Get file diffs for this session.""" + return await self._client.get_session_diff(self.id) + + # ── Share / Revert ──────────────────────────────────────────── + + async def share(self) -> None: + """Share this session (create shareable link).""" + self.info = await self._client.share_session(self.id) + + async def unshare(self) -> None: + """Remove session sharing.""" + self.info = await self._client.unshare_session(self.id) + + async def revert(self, *, message_id: str, part_id: str | None = None) -> None: + """Revert this session to a specific message.""" + self.info = await self._client.revert_session( + self.id, message_id=message_id, part_id=part_id + ) + + async def unrevert(self) -> None: + """Undo a revert.""" + self.info = await self._client.unrevert_session(self.id) + + # ── Permissions ─────────────────────────────────────────────── + + async def list_permissions(self) -> list[PermissionAskedProperties]: + """Get pending permission requests for this session.""" + return await self._client.list_permissions(self.id) + + async def reply_permission( + self, + permission_id: str, + reply: PermissionReplyRequest, + ) -> bool: + """Reply to a permission request.""" + return await self._client.reply_permission(self.id, permission_id, reply) + + def __repr__(self) -> str: + return f"SessionHandle(id={self.id!r}, title={self.title!r})" + + +# ── Client ──────────────────────────────────────────────────────────── + + +class OpenCodeClient: + """Async HTTP client for the OpenCode server API. + + All methods return OpenCode SDK models — no agentpool-specific types. + Uses httpx for HTTP and SSE streaming. + + Session-scoped operations are available both as flat methods on the client + (taking ``session_id``) and as methods on :class:`SessionHandle` objects + returned by :meth:`create_session` and :meth:`session`. + """ + + def __init__( + self, + base_url: str = "http://localhost:3000", + *, + timeout: float = 30.0, + sse_timeout: float | None = None, + ) -> None: + """Initialize the OpenCode client. + + Args: + base_url: Base URL of the OpenCode server. + timeout: Default timeout for HTTP requests in seconds. + sse_timeout: Timeout for SSE connections (None = no timeout). + """ + self.base_url = base_url.rstrip("/") + self._timeout = timeout + self._sse_timeout = sse_timeout + self._client: httpx.AsyncClient | None = None + + async def __aenter__(self) -> Self: + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=self._timeout) + return self + + async def __aexit__(self, *args: object) -> None: + if self._client: + await self._client.aclose() + self._client = None + + @property + def client(self) -> httpx.AsyncClient: + """Return the active httpx client, raising if not connected.""" + if self._client is None: + msg = "Client not connected. Use 'async with OpenCodeClient(...) as client:'" + raise RuntimeError(msg) + return self._client + + # ── Helpers ─────────────────────────────────────────────────────── + + async def _get(self, path: str, **params: Any) -> Any: + """GET request, returning parsed JSON.""" + filtered = {k: v for k, v in params.items() if v is not None} + resp = await self.client.get(path, params=filtered) + resp.raise_for_status() + return resp.json() + + async def _post(self, path: str, json: Any = None) -> Any: + """POST request, returning parsed JSON (or None for 204).""" + resp = await self.client.post(path, json=json) + resp.raise_for_status() + if resp.status_code == HTTPStatus.NO_CONTENT: + return None + return resp.json() + + async def _patch(self, path: str, json: Any = None) -> Any: + """PATCH request, returning parsed JSON.""" + resp = await self.client.patch(path, json=json) + resp.raise_for_status() + return resp.json() + + async def _delete(self, path: str) -> Any: + """DELETE request, returning parsed JSON.""" + resp = await self.client.delete(path) + resp.raise_for_status() + return resp.json() + + @staticmethod + def _dump(model: Any) -> dict[str, Any]: + """Serialize an OpenCode model to a JSON-compatible dict.""" + result: dict[str, Any] = model.model_dump(by_alias=True, exclude_none=True) + return result + + # ── Global / Health ─────────────────────────────────────────────── + + async def health(self) -> HealthResponse: + """Check server health.""" + data = await self._get("/global/health") + return HealthResponse.model_validate(data) + + async def get_global_config(self) -> Config: + """Get global configuration.""" + data = await self._get("/global/config") + return Config.model_validate(data) + + async def update_global_config(self, config: Config) -> Config: + """Update global configuration.""" + data = await self._patch("/global/config", json=self._dump(config)) + return Config.model_validate(data) + + async def dispose(self) -> bool: + """Dispose all instances and release resources.""" + data = await self._post("/global/dispose") + return bool(data) + + # ── SSE Events ──────────────────────────────────────────────────── + + async def events(self, *, wrap_payload: bool = False) -> AsyncIterator[Event]: + """Stream SSE events from the server. + + Args: + wrap_payload: If True, use /global/event (payload-wrapped); + otherwise use /event (raw events). + + Yields: + Parsed Event models. + """ + path = "/global/event" if wrap_payload else "/event" + timeout = httpx.Timeout(self._timeout, read=self._sse_timeout) + async with ( + httpx.AsyncClient(base_url=self.base_url, timeout=timeout) as sse_client, + sse_client.stream("GET", path) as response, + ): + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw: + continue + json_data: dict[str, Any] = anyenv.load_json(raw, return_type=dict) + if wrap_payload and "payload" in json_data: + json_data = json_data["payload"] + yield _event_adapter.validate_python(json_data) + + # ── App / Project ───────────────────────────────────────────────── + + async def get_app(self) -> App: + """Get application info.""" + data = await self._get("/app") + return App.model_validate(data) + + async def list_projects(self) -> list[Project]: + """List all projects.""" + data = await self._get("/project") + return [Project.model_validate(p) for p in data] + + async def get_current_project(self) -> Project: + """Get the current project.""" + data = await self._get("/project/current") + return Project.model_validate(data) + + async def get_path_info(self) -> PathInfo: + """Get path information (cwd, root, etc.).""" + data = await self._get("/path") + return PathInfo.model_validate(data) + + async def get_vcs_info(self) -> VcsInfo: + """Get VCS (git) information.""" + data = await self._get("/vcs") + return VcsInfo.model_validate(data) + + # ── Config / Providers ──────────────────────────────────────────── + + async def get_config(self) -> Config: + """Get configuration.""" + data = await self._get("/config") + return Config.model_validate(data) + + async def update_config(self, config: Config) -> Config: + """Update configuration.""" + data = await self._patch("/config", json=self._dump(config)) + return Config.model_validate(data) + + # ── Sessions ────────────────────────────────────────────────────── + + async def list_sessions( + self, + *, + directory: str | None = None, + roots: bool | None = None, + start: int | None = None, + search: str | None = None, + limit: int | None = None, + ) -> list[SessionHandle]: + """List sessions. + + Args: + directory: Filter by project directory. + roots: Only return root sessions (no parent). + start: Filter sessions updated on or after this timestamp (ms). + search: Filter by title (case-insensitive). + limit: Maximum number of sessions to return. + """ + data = await self._get( + "/session", + directory=directory, + roots=roots, + start=start, + search=search, + limit=limit, + ) + return [SessionHandle(self, Session.model_validate(s)) for s in data] + + async def create_session( + self, + request: SessionCreateRequest | None = None, + ) -> SessionHandle: + """Create a new session.""" + json_data = self._dump(request) if request else None + data = await self._post("/session", json=json_data) + return SessionHandle(self, Session.model_validate(data)) + + async def session(self, session_id: str) -> SessionHandle: + """Get a session handle by ID.""" + data = await self._get(f"/session/{session_id}") + return SessionHandle(self, Session.model_validate(data)) + + async def update_session( + self, + session_id: str, + request: SessionUpdateRequest, + ) -> Session: + """Update a session (e.g. title, archive).""" + data = await self._patch(f"/session/{session_id}", json=self._dump(request)) + return Session.model_validate(data) + + async def delete_session(self, session_id: str) -> bool: + """Delete a session.""" + data = await self._delete(f"/session/{session_id}") + return bool(data) + + async def get_session_status(self) -> dict[str, SessionStatus]: + """Get status for all sessions (only non-idle returned).""" + data = await self._get("/session/status") + return {k: SessionStatus.model_validate(v) for k, v in data.items()} + + async def get_session_children(self, session_id: str) -> list[Session]: + """Get child sessions.""" + data = await self._get(f"/session/{session_id}/children") + return [Session.model_validate(s) for s in data] + + async def abort_session(self, session_id: str) -> bool: + """Abort a busy session.""" + data = await self._post(f"/session/{session_id}/abort") + return bool(data) + + async def fork_session( + self, + session_id: str, + request: SessionForkRequest | None = None, + ) -> SessionHandle: + """Fork a session, optionally from a specific message.""" + json_data = self._dump(request) if request else None + data = await self._post(f"/session/{session_id}/fork", json=json_data) + return SessionHandle(self, Session.model_validate(data)) + + async def init_session( + self, + session_id: str, + request: SessionInitRequest | None = None, + ) -> MessageWithParts: + """Initialize a session (create AGENTS.md).""" + json_data = self._dump(request) if request else None + data = await self._post(f"/session/{session_id}/init", json=json_data) + return MessageWithParts.model_validate(data) + + async def get_session_todos(self, session_id: str) -> list[Todo]: + """Get todos for a session.""" + data = await self._get(f"/session/{session_id}/todo") + return [Todo.model_validate(t) for t in data] + + async def get_session_diff(self, session_id: str) -> list[FileDiff]: + """Get file diffs for a session.""" + data = await self._get(f"/session/{session_id}/diff") + return [FileDiff.model_validate(d) for d in data] + + async def shell( + self, + session_id: str, + request: ShellRequest, + ) -> MessageWithParts: + """Run a shell command in a session.""" + data = await self._post(f"/session/{session_id}/shell", json=self._dump(request)) + return MessageWithParts.model_validate(data) + + async def summarize( + self, + session_id: str, + request: SummarizeRequest | None = None, + ) -> MessageWithParts: + """Summarize/compact a session.""" + json_data = self._dump(request) if request else None + data = await self._post(f"/session/{session_id}/summarize", json=json_data) + return MessageWithParts.model_validate(data) + + async def share_session(self, session_id: str) -> Session: + """Share a session (create shareable link).""" + data = await self._post(f"/session/{session_id}/share") + return Session.model_validate(data) + + async def unshare_session(self, session_id: str) -> Session: + """Remove session sharing.""" + data = await self._delete(f"/session/{session_id}/share") + return Session.model_validate(data) + + async def revert_session( + self, + session_id: str, + *, + message_id: str, + part_id: str | None = None, + ) -> Session: + """Revert a session to a specific message.""" + body: dict[str, str | None] = {"message_id": message_id, "part_id": part_id} + data = await self._post(f"/session/{session_id}/revert", json=body) + return Session.model_validate(data) + + async def unrevert_session(self, session_id: str) -> Session: + """Undo a revert.""" + data = await self._post(f"/session/{session_id}/unrevert") + return Session.model_validate(data) + + async def execute_command( + self, + session_id: str, + request: CommandRequest, + ) -> MessageWithParts: + """Execute a slash command in a session.""" + data = await self._post(f"/session/{session_id}/command", json=self._dump(request)) + return MessageWithParts.model_validate(data) + + # ── Messages ────────────────────────────────────────────────────── + + async def list_messages( + self, + session_id: str, + *, + limit: int | None = None, + ) -> list[MessageWithParts]: + """List messages in a session.""" + data = await self._get(f"/session/{session_id}/message", limit=limit) + return [MessageWithParts.model_validate(m) for m in data] + + async def send_message( + self, + session_id: str, + request: MessageRequest, + ) -> MessageWithParts: + """Send a message and wait for the agent's response.""" + data = await self._post(f"/session/{session_id}/message", json=self._dump(request)) + return MessageWithParts.model_validate(data) + + async def send_message_async(self, session_id: str, request: MessageRequest) -> None: + """Send a message asynchronously (returns immediately, listen to SSE for updates).""" + await self._post(f"/session/{session_id}/prompt_async", json=self._dump(request)) + + async def get_message(self, session_id: str, message_id: str) -> MessageWithParts: + """Get a specific message.""" + data = await self._get(f"/session/{session_id}/message/{message_id}") + return MessageWithParts.model_validate(data) + + async def delete_message(self, session_id: str, message_id: str) -> bool: + """Delete a message and all its parts.""" + data = await self._delete(f"/session/{session_id}/message/{message_id}") + return bool(data) + + # ── Permissions ─────────────────────────────────────────────────── + + async def list_permissions(self, session_id: str) -> list[PermissionAskedProperties]: + """Get pending permission requests for a session.""" + data = await self._get(f"/session/{session_id}/permissions") + return [PermissionAskedProperties.model_validate(p) for p in data] + + async def reply_permission( + self, + session_id: str, + permission_id: str, + reply: PermissionReplyRequest, + ) -> bool: + """Reply to a permission request.""" + data = await self._post( + f"/session/{session_id}/permissions/{permission_id}", + json=self._dump(reply), + ) + return bool(data) + + # ── Questions ───────────────────────────────────────────────────── + + async def list_questions(self) -> list[QuestionRequest]: + """Get pending question requests.""" + data = await self._get("/question/") + return [QuestionRequest.model_validate(q) for q in data] + + async def reply_question( + self, + request_id: str, + reply: QuestionReply, + ) -> bool: + """Reply to a question request.""" + data = await self._post(f"/question/{request_id}/reply", json=self._dump(reply)) + return bool(data) + + async def reject_question(self, request_id: str) -> bool: + """Reject a question request.""" + data = await self._post(f"/question/{request_id}/reject") + return bool(data) + + # ── Agent / Skills / Commands ───────────────────────────────────── + + async def list_agents(self) -> list[Agent]: + """List available agents.""" + from opencode_sdk.models.agent import Agent + + data = await self._get("/agent") + return [Agent.model_validate(a) for a in data] + + async def list_skills(self) -> list[SkillInfo]: + """List available skills/tools.""" + from opencode_sdk.models.agent import SkillInfo + + data = await self._get("/skill") + return [SkillInfo.model_validate(s) for s in data] + + async def list_commands(self) -> list[Command]: + """List available slash commands.""" + from opencode_sdk.models.agent import Command + + data = await self._get("/command") + return [Command.model_validate(c) for c in data] + + # ── MCP ─────────────────────────────────────────────────────────── + + async def list_mcp_servers(self) -> list[MCPStatus]: + """List MCP server statuses.""" + data = await self._get("/mcp") + return [MCPStatus.model_validate(s) for s in data] + + async def add_mcp_server(self, request: AddMcpServerRequest) -> MCPStatus: + """Add an MCP server dynamically.""" + data = await self._post("/mcp", json=self._dump(request)) + return MCPStatus.model_validate(data) + + async def connect_mcp_server(self, name: str) -> MCPStatus: + """Connect/reconnect an MCP server.""" + data = await self._post(f"/mcp/{name}/connect") + return MCPStatus.model_validate(data) + + async def disconnect_mcp_server(self, name: str) -> MCPStatus: + """Disconnect an MCP server.""" + data = await self._post(f"/mcp/{name}/disconnect") + return MCPStatus.model_validate(data) + + # ── Logging ─────────────────────────────────────────────────────── + + async def log( + self, + message: str, + *, + service: str = "opencode-client", + level: LogLevel = "info", + ) -> None: + """Send a log message to the server.""" + from opencode_sdk.models.mcp import LogRequest + + req = LogRequest(service=service, level=level, message=message) + await self._post("/log", json=self._dump(req)) diff --git a/src/opencode_sdk/helpers.py b/src/opencode_sdk/helpers.py new file mode 100644 index 000000000..1e970b6c1 --- /dev/null +++ b/src/opencode_sdk/helpers.py @@ -0,0 +1,102 @@ +"""Helper functions for OpenCode SQLite storage provider. + +Stateless conversion and utility functions for working with OpenCode's +SQLite-based format. Converts between raw database rows and domain models. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import TypeAdapter + +from agentpool.log import get_logger +from opencode_sdk.models.message import MessageInfo +from opencode_sdk.models.parts import Part, ReasoningPart, TextPart + + +logger = get_logger(__name__) + +_message_info_adapter: TypeAdapter[MessageInfo] = TypeAdapter(MessageInfo) +_part_adapter: TypeAdapter[Part] = TypeAdapter(Part) + + +def parse_message_info(data: dict[str, Any], *, message_id: str, session_id: str) -> MessageInfo: + """Parse a message JSON data dict into a typed MessageInfo model. + + Injects the DB column fields (id, sessionID) into the data dict before + validation, matching how OpenCode itself reconstructs messages from DB rows. + + Args: + data: The JSON 'data' field from the message table + message_id: Message ID from the DB id column + session_id: Session ID from the DB session_id column + + Returns: + Validated UserMessage or AssistantMessage + """ + data["id"] = message_id + data["sessionID"] = session_id + return _message_info_adapter.validate_python(data) + + +def parse_part(data: dict[str, Any], *, part_id: str, message_id: str, session_id: str) -> Part: + """Parse a part JSON data dict into a typed Part model. + + Injects the DB column fields (id, messageID, sessionID) into the data dict + before validation, matching how OpenCode itself reconstructs parts from DB rows. + + Args: + data: The JSON 'data' field from the part table + part_id: Part ID from the DB id column + message_id: Message ID from the DB message_id column + session_id: Session ID from the DB session_id column + + Returns: + Validated Part (TextPart, ToolPart, ReasoningPart, etc.) + """ + data["id"] = part_id + data["messageID"] = message_id + data["sessionID"] = session_id + return _part_adapter.validate_python(data) + + +def extract_text_content(parts: list[Part]) -> str: + """Extract text content from typed parts for display. + + Groups consecutive reasoning parts into a single block + and only wraps them if there are also non-reasoning parts present. + + Args: + parts: List of typed Part models + + Returns: + Combined text content from all text and reasoning parts + """ + text_segments: list[str] = [] + reasoning_segments: list[str] = [] + has_text = False + + for part in parts: + if isinstance(part, TextPart): + if part.text: + has_text = True + # Flush any accumulated reasoning before this text + if reasoning_segments: + combined = "\n".join(reasoning_segments) + text_segments.append(f"\n{combined}\n") + reasoning_segments.clear() + text_segments.append(part.text) + elif isinstance(part, ReasoningPart) and part.text: + reasoning_segments.append(part.text) + + # Flush remaining reasoning + if reasoning_segments: + combined = "\n".join(reasoning_segments) + if has_text: + text_segments.append(f"\n{combined}\n") + else: + # Entire message is thinking — no need for wrapper tags + text_segments.append(combined) + + return "\n".join(text_segments) diff --git a/src/agentpool_server/opencode_server/models/__init__.py b/src/opencode_sdk/models/__init__.py similarity index 85% rename from src/agentpool_server/opencode_server/models/__init__.py rename to src/opencode_sdk/models/__init__.py index 7c93289e1..73698ce3d 100644 --- a/src/agentpool_server/opencode_server/models/__init__.py +++ b/src/opencode_sdk/models/__init__.py @@ -5,8 +5,10 @@ - by_alias=True serialization by default """ -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ( +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ( + APIError, + APIErrorData, FileDiff, FileDiffStatus, ModelRef, @@ -15,7 +17,7 @@ TokenCache, Tokens, ) -from agentpool_server.opencode_server.models.app import ( +from opencode_sdk.models.app import ( App, AppTimeInfo, HealthResponse, @@ -25,7 +27,7 @@ ProjectUpdateRequest, VcsInfo, ) -from agentpool_server.opencode_server.models.provider import ( +from opencode_sdk.models.provider import ( Model, ModelCost, ModelLimit, @@ -34,7 +36,7 @@ ProviderListResponse, ProvidersResponse, ) -from agentpool_server.opencode_server.models.session import ( +from opencode_sdk.models.session import ( Session, SessionCreateRequest, SessionForkRequest, @@ -48,14 +50,18 @@ SummarizeRequest, Todo, ) -from agentpool_server.opencode_server.models.message import ( - APIError, - APIErrorData, +from opencode_sdk.models.inputs import ( + FilePartInput, + AgentPartInput, + SubtaskPartInput, + TextPartInput, + PartInput, +) +from opencode_sdk.models.message import ( AssistantMessage, CommandRequest, ContextOverflowError, ContextOverflowErrorData, - FilePartInput, MessageAbortedError, MessageAbortedErrorData, MessageError, @@ -63,29 +69,26 @@ MessageOutputLengthError, MessageOutputLengthErrorData, MessagePath, - AgentPartInput, OutputFormat, + FinishReason, OutputFormatJsonSchema, OutputFormatText, - SubtaskPartInput, MessageRequest, MessageSummary, MessageTime, + AnyMessageWithParts, MessageWithParts, - PartInput, ProviderAuthError, ProviderAuthErrorData, ShellRequest, StructuredOutputError, StructuredOutputErrorData, - TextPartInput, UnknownError, UnknownErrorData, UserMessage, ) -from agentpool_server.opencode_server.models.parts import ( +from opencode_sdk.models.parts import ( AgentPart, - APIErrorInfo, CompactionPart, FilePart, Part, @@ -93,6 +96,7 @@ PatchPart, ReasoningPart, RetryPart, + ResourceSource, SnapshotPart, StepFinishPart, StepStartPart, @@ -109,47 +113,56 @@ ToolStatePending, ToolStateRunning, ) -from agentpool_server.opencode_server.models.file import ( +from opencode_sdk.models.file import ( FileContent, FileNode, FileStatus, FindMatch, Symbol, SubmatchInfo, + FileType, ) -from agentpool_server.opencode_server.models.agent import ( +from opencode_sdk.models.agent import ( Agent, + ApiAuthInfo, AuthInfo, Command, + OAuthAuthInfo, ProviderAuthAuthorization, ProviderAuthMethod, SkillInfo, + WellKnownAuthInfo, + WorkspaceCreateRequest, + WorkspaceInfo, WorktreeCreateRequest, WorktreeInfo, WorktreeRemoveRequest, WorktreeResetRequest, ) -from agentpool_server.opencode_server.models.diagnostics import ( +from opencode_sdk.models.diagnostics import ( FormatterStatus, Diagnostic, DiagnosticRange, ) -from agentpool_server.opencode_server.models.pty import ( +from opencode_sdk.models.pty import ( PtyCreateRequest, PtyInfo, PtySize, PtyUpdateRequest, ) -from agentpool_server.opencode_server.models.events import ( +from opencode_sdk.models.events import ( CommandExecutedEvent, Event, FileEditedEvent, QuestionRepliedEvent, QuestionRejectedEvent, LspStatus, + TuiToastShowEvent, + TodoUpdatedEvent, PtyCreatedEvent, PtyDeletedEvent, + QuestionAskedEvent, PtyExitedEvent, PtyUpdatedEvent, LspUpdatedEvent, @@ -191,14 +204,16 @@ SessionUpdatedEvent, TuiSessionSelectEvent, ) -from agentpool_server.opencode_server.models.mcp import ( +from opencode_sdk.models.mcp import ( + AddMcpServerRequest, LogRequest, McpAuthorizationResponse, MCPStatus, McpResource, + MCPConnectionStatus, ) -from agentpool_server.opencode_server.models.config import Config -from agentpool_server.opencode_server.models.question import ( +from opencode_sdk.models.config import Config +from opencode_sdk.models.question import ( QuestionInfo, QuestionOption, QuestionReply, @@ -209,10 +224,12 @@ __all__ = [ "APIError", "APIErrorData", - "APIErrorInfo", + "AddMcpServerRequest", "Agent", "AgentPart", "AgentPartInput", + "AnyMessageWithParts", + "ApiAuthInfo", "App", "AppTimeInfo", "AssistantMessage", @@ -236,13 +253,16 @@ "FilePart", "FilePartInput", "FileStatus", + "FileType", "FileWatcherUpdatedEvent", "FindMatch", + "FinishReason", "FormatterStatus", "HealthResponse", "LogRequest", "LspStatus", "LspUpdatedEvent", + "MCPConnectionStatus", "MCPStatus", "McpAuthorizationResponse", "McpResource", @@ -266,6 +286,7 @@ "ModelCost", "ModelLimit", "ModelRef", + "OAuthAuthInfo", "OpenCodeBaseModel", "OutputFormat", "OutputFormatJsonSchema", @@ -305,6 +326,7 @@ "PtySize", "PtyUpdateRequest", "PtyUpdatedEvent", + "QuestionAskedEvent", "QuestionInfo", "QuestionOption", "QuestionRejectedEvent", @@ -313,6 +335,7 @@ "QuestionRequest", "QuestionToolInfo", "ReasoningPart", + "ResourceSource", "RetryPart", "ServerConnectedEvent", "ServerHeartbeatEvent", @@ -362,6 +385,7 @@ "TimeStartEndCompacted", "TimeStartEndOptional", "Todo", + "TodoUpdatedEvent", "TokenCache", "Tokens", "ToolPart", @@ -371,11 +395,15 @@ "ToolStatePending", "ToolStateRunning", "TuiSessionSelectEvent", + "TuiToastShowEvent", "UnknownError", "UnknownErrorData", "UserMessage", "VcsBranchUpdatedEvent", "VcsInfo", + "WellKnownAuthInfo", + "WorkspaceCreateRequest", + "WorkspaceInfo", "WorktreeCreateRequest", "WorktreeInfo", "WorktreeRemoveRequest", diff --git a/src/agentpool_server/opencode_server/models/agent.py b/src/opencode_sdk/models/agent.py similarity index 54% rename from src/agentpool_server/opencode_server/models/agent.py rename to src/opencode_sdk/models/agent.py index 21fff38e8..4b0a0225f 100644 --- a/src/agentpool_server/opencode_server/models/agent.py +++ b/src/opencode_sdk/models/agent.py @@ -2,14 +2,28 @@ from __future__ import annotations -from typing import Literal +from typing import Annotated, Any, Literal +from annotated_types import Predicate from pydantic import Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ModelRef # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ModelRef # noqa: TC001 +ThemeColor = Literal["primary", "secondary", "accent", "success", "warning", "error", "info"] +"""Predefined theme color names.""" + +HexColor = Annotated[ + str, + Predicate( + lambda s: len(s) == 7 and s[0] == "#" and all(c in "0123456789abcdefABCDEF" for c in s[1:]) # noqa: PLR2004 + ), +] +"""Hex color code in #RRGGBB format.""" + +AgentColor = HexColor | ThemeColor +"""Agent color: hex code (#FF5733) or theme color name.""" PermissionBehavior = Literal["ask", "allow", "deny"] AgentMode = Literal["subagent", "primary", "all"] @@ -36,7 +50,7 @@ class Agent(OpenCodeBaseModel): default: bool | None = None top_p: float | None = None temperature: float | None = None - color: str | None = None + color: AgentColor | None = None permission: AgentPermission = Field(default_factory=AgentPermission) model: ModelRef | None = None prompt: str | None = None @@ -127,17 +141,88 @@ class WorktreeResetRequest(OpenCodeBaseModel): """Worktree directory path to reset.""" -class AuthInfo(OpenCodeBaseModel): - """Authentication credential info.""" +class OAuthAuthInfo(OpenCodeBaseModel): + """OAuth authentication credentials.""" - type: str = "api_key" - """Auth type (e.g., 'api_key', 'oauth').""" + type: Literal["oauth"] + """Auth type discriminator.""" - token: str | None = None - """API key or access token.""" + refresh: str + """Refresh token.""" - refresh: str | None = None - """Refresh token (for OAuth).""" + access: str + """Access token.""" - expires: int | None = None + expires: int """Token expiry timestamp.""" + + account_id: str | None = None + """Optional account identifier.""" + + enterprise_url: str | None = None + """Optional enterprise URL.""" + + +class ApiAuthInfo(OpenCodeBaseModel): + """API key authentication credentials.""" + + type: Literal["api"] + """Auth type discriminator.""" + + key: str + """API key.""" + + +class WellKnownAuthInfo(OpenCodeBaseModel): + """Well-known authentication credentials.""" + + type: Literal["wellknown"] + """Auth type discriminator.""" + + key: str + """Key identifier.""" + + token: str + """Authentication token.""" + + +AuthInfo = OAuthAuthInfo | ApiAuthInfo | WellKnownAuthInfo +"""Authentication credentials (discriminated union on 'type').""" + + +class WorkspaceInfo(OpenCodeBaseModel): + """Workspace information matching OpenCode SDK type.""" + + id: str + """Workspace identifier.""" + + type: Literal["worktree"] | str # noqa: PYI051 + """Workspace type.""" + + branch: str | None = None + """Git branch associated with the workspace.""" + + name: str | None = None + """Workspace display name.""" + + directory: str | None = None + """Directory path of the workspace.""" + + extra: Any | None = None + """Additional workspace-specific data.""" + + project_id: str + """ID of the project this workspace belongs to.""" + + +class WorkspaceCreateRequest(OpenCodeBaseModel): + """Request to create a workspace.""" + + type: Literal["worktree"] | str # noqa: PYI051 + """Workspace type.""" + + branch: str | None = None + """Git branch for the workspace.""" + + extra: Any | None = None + """Additional workspace-specific data.""" diff --git a/src/agentpool_server/opencode_server/models/app.py b/src/opencode_sdk/models/app.py similarity index 97% rename from src/agentpool_server/opencode_server/models/app.py rename to src/opencode_sdk/models/app.py index b6d584f5e..2e0fdb7cf 100644 --- a/src/agentpool_server/opencode_server/models/app.py +++ b/src/opencode_sdk/models/app.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Self -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel _APP_NAME = "opencode" diff --git a/src/agentpool_server/opencode_server/models/base.py b/src/opencode_sdk/models/base.py similarity index 100% rename from src/agentpool_server/opencode_server/models/base.py rename to src/opencode_sdk/models/base.py diff --git a/src/agentpool_server/opencode_server/models/common.py b/src/opencode_sdk/models/common.py similarity index 62% rename from src/agentpool_server/opencode_server/models/common.py rename to src/opencode_sdk/models/common.py index 6616ae60f..646cc6c67 100644 --- a/src/agentpool_server/opencode_server/models/common.py +++ b/src/opencode_sdk/models/common.py @@ -7,13 +7,14 @@ from pydantic import Field from agentpool.utils.time_utils import now_ms -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel if TYPE_CHECKING: + from pydantic_ai import RequestUsage, RunUsage from pydantic_ai.usage import UsageBase - from agentpool.utils.streams import FileChange + from agentpool.utils.file_ops_tracker import FileChange FileDiffStatus = Literal["added", "deleted", "modified"] @@ -23,6 +24,8 @@ class TimeCreatedUpdated(OpenCodeBaseModel): created: int updated: int + archived: int | None = None + """Timestamp when archived (ms since epoch), or None if not archived.""" class TimeCreated(OpenCodeBaseModel): @@ -55,6 +58,10 @@ class TokenCache(OpenCodeBaseModel): read: int = 0 write: int = 0 + def add(self, tokens: TokenCache) -> None: + self.read += tokens.read + self.write += tokens.write + class Tokens(OpenCodeBaseModel): """Token usage for one assistant message. @@ -69,6 +76,13 @@ class Tokens(OpenCodeBaseModel): cache: TokenCache = Field(default_factory=TokenCache) total: int | None = None + def add(self, tokens: Tokens) -> None: + self.input += tokens.input + self.output += tokens.output + self.reasoning += tokens.reasoning + self.cache.add(tokens.cache) + self.total = (self.total or 0) + (tokens.total or 0) + @classmethod def from_pydantic_ai(cls, usage: UsageBase) -> Tokens: """Create from a pydantic-ai Usage object. @@ -85,6 +99,46 @@ def from_pydantic_ai(cls, usage: UsageBase) -> Tokens: total=usage.total_tokens + reasoning, ) + def to_request_usage(self) -> RequestUsage: + """Convert to a pydantic-ai Usage object for request usage.""" + from pydantic_ai import RequestUsage + + return RequestUsage( + input_tokens=self.input, + output_tokens=self.output, + cache_read_tokens=self.cache.read, + cache_write_tokens=self.cache.write, + ) + + def to_run_usage(self) -> RunUsage: + """Convert to a pydantic-ai RunUsage object for run usage.""" + from pydantic_ai import RunUsage + + return RunUsage( + input_tokens=self.input, + output_tokens=self.output, + cache_read_tokens=self.cache.read, + cache_write_tokens=self.cache.write, + ) + + +class APIErrorData(OpenCodeBaseModel): + """Data for API errors.""" + + message: str + status_code: int | None = None + is_retryable: bool = False + response_headers: dict[str, str] | None = None + response_body: str | None = None + metadata: dict[str, str] | None = None + + +class APIError(OpenCodeBaseModel): + """API error.""" + + name: Literal["APIError"] = Field(default="APIError", init=False) + data: APIErrorData + class TextSpan(OpenCodeBaseModel): """A text span in user input (value + start/end offsets).""" diff --git a/src/agentpool_server/opencode_server/models/config.py b/src/opencode_sdk/models/config.py similarity index 98% rename from src/agentpool_server/opencode_server/models/config.py rename to src/opencode_sdk/models/config.py index 06bf6cdde..49afc032a 100644 --- a/src/agentpool_server/opencode_server/models/config.py +++ b/src/opencode_sdk/models/config.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel DEFAULT_IGNORE = ["node_modules/**", "__pycache__/**", ".venv/**", "*.pyc", ".mypy_cache/**"] diff --git a/src/agentpool_server/opencode_server/models/diagnostics.py b/src/opencode_sdk/models/diagnostics.py similarity index 88% rename from src/agentpool_server/opencode_server/models/diagnostics.py rename to src/opencode_sdk/models/diagnostics.py index a69d75401..e285acbd0 100644 --- a/src/agentpool_server/opencode_server/models/diagnostics.py +++ b/src/opencode_sdk/models/diagnostics.py @@ -2,9 +2,14 @@ from __future__ import annotations +from typing import Literal + from pydantic import BaseModel +SeverityLevel = Literal[1, 2, 3, 4] + + class DiagnosticPosition(BaseModel): """Position in a text document.""" @@ -37,7 +42,8 @@ class Diagnostic(BaseModel): range: DiagnosticRange message: str - severity: int | None = None # 1=Error, 2=Warning, 3=Info, 4=Hint + severity: SeverityLevel | None = None + """1=Error, 2=Warning, 3=Info, 4=Hint""" code: str | int | None = None source: str | None = None diff --git a/src/agentpool_server/opencode_server/models/events.py b/src/opencode_sdk/models/events.py similarity index 96% rename from src/agentpool_server/opencode_server/models/events.py rename to src/opencode_sdk/models/events.py index ddddc006e..55d0b26a7 100644 --- a/src/agentpool_server/opencode_server/models/events.py +++ b/src/opencode_sdk/models/events.py @@ -6,28 +6,31 @@ from pydantic import Field -from agentpool_server.opencode_server.models.app import Project # noqa: TC001 -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import FileDiff # noqa: TC001 -from agentpool_server.opencode_server.models.message import MessageInfo # noqa: TC001 -from agentpool_server.opencode_server.models.parts import Part # noqa: TC001 -from agentpool_server.opencode_server.models.pty import PtyInfo # noqa: TC001 -from agentpool_server.opencode_server.models.question import ( # noqa: TC001 +from opencode_sdk.models.app import Project # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import FileDiff # noqa: TC001 +from opencode_sdk.models.message import MessageInfo # noqa: TC001 +from opencode_sdk.models.parts import Part # noqa: TC001 +from opencode_sdk.models.pty import PtyInfo # noqa: TC001 +from opencode_sdk.models.question import ( # noqa: TC001 QuestionInfo, QuestionToolInfo, ) -from agentpool_server.opencode_server.models.session import ( # noqa: TC001 +from opencode_sdk.models.session import ( # noqa: TC001 Session, SessionStatus, SessionStatusType, + Todo, ) Variant = Literal["info", "success", "warning", "error"] -TodoPriority = Literal["high", "medium", "low"] FileUpdateEvent = Literal["add", "change", "unlink"] ConnectionStatus = Literal["connected", "error"] +PermissionReply = Literal["once", "always", "reject"] +"""Permission reply type matching OpenCode's PermissionNext.Reply.""" + class EmptyProperties(OpenCodeBaseModel): """Empty properties object.""" @@ -299,10 +302,6 @@ def create(cls, session_id: str, message_id: str, part_id: str) -> Self: return cls(properties=props) -PermissionReply = Literal["once", "always", "reject"] -"""Permission reply type matching OpenCode's PermissionNext.Reply.""" - - class PermissionReplyRequest(OpenCodeBaseModel): """Request body for responding to a permission request.""" @@ -581,22 +580,6 @@ def create(cls, session_id: str) -> Self: # ============================================================================= -class Todo(OpenCodeBaseModel): - """A single todo item.""" - - id: str - """Unique identifier for the todo item.""" - - content: str - """Brief description of the task.""" - - status: Literal["pending", "in_progress", "completed", "cancelled"] - """Current status: pending, in_progress, completed, cancelled.""" - - priority: TodoPriority - """Priority level: high, medium, low.""" - - class TodoUpdatedProperties(OpenCodeBaseModel): """Properties for todo updated event.""" diff --git a/src/agentpool_server/opencode_server/models/file.py b/src/opencode_sdk/models/file.py similarity index 89% rename from src/agentpool_server/opencode_server/models/file.py rename to src/opencode_sdk/models/file.py index 3d8d309f6..76a27c003 100644 --- a/src/agentpool_server/opencode_server/models/file.py +++ b/src/opencode_sdk/models/file.py @@ -6,8 +6,11 @@ from pydantic import BaseModel, Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import FileDiffStatus # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import FileDiffStatus # noqa: TC001 + + +FileType = Literal["file", "directory"] class FileNode(OpenCodeBaseModel): @@ -15,7 +18,7 @@ class FileNode(OpenCodeBaseModel): name: str path: str - type: Literal["file", "directory"] + type: FileType size: int | None = None diff --git a/src/opencode_sdk/models/inputs.py b/src/opencode_sdk/models/inputs.py new file mode 100644 index 000000000..9f1942c88 --- /dev/null +++ b/src/opencode_sdk/models/inputs.py @@ -0,0 +1,58 @@ +"""Message related models.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field + +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ModelRef, TextSpan # noqa: TC001 +from opencode_sdk.models.parts import FilePartSource # noqa: TC001 + + +class TextPartInput(OpenCodeBaseModel): + """Text part for input.""" + + type: Literal["text"] = Field(default="text", init=False) + text: str + + +class FilePartInput(OpenCodeBaseModel): + """File part for input (image, document, etc.).""" + + type: Literal["file"] = Field(default="file", init=False) + mime: str + filename: str | None = None + url: str # Can be data: URI or file path + source: FilePartSource | None = None + + +class AgentPartInput(OpenCodeBaseModel): + """Agent mention part for input - references a sub-agent to delegate to. + + When a user types @agent-name in the prompt, this part is created. + """ + + type: Literal["agent"] = Field(default="agent", init=False) + name: str + """Name of the agent to delegate to.""" + source: TextSpan | None = None + """Source location in the original prompt text.""" + + +class SubtaskPartInput(OpenCodeBaseModel): + """Subtask part for input - spawns a subtask to another agent.""" + + type: Literal["subtask"] = Field(default="subtask", init=False) + prompt: str + """The prompt for the subtask.""" + description: str + """Description of what the subtask does.""" + agent: str + """The agent to handle this subtask.""" + model: ModelRef | None = None + """Optional model to use for the subtask.""" + + +PartInput = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput diff --git a/src/agentpool_server/opencode_server/models/mcp.py b/src/opencode_sdk/models/mcp.py similarity index 64% rename from src/agentpool_server/opencode_server/models/mcp.py rename to src/opencode_sdk/models/mcp.py index b576c5bc8..0231742d9 100644 --- a/src/agentpool_server/opencode_server/models/mcp.py +++ b/src/opencode_sdk/models/mcp.py @@ -4,7 +4,7 @@ from pydantic import Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel MCPConnectionStatus = Literal["connected", "disconnected", "error"] @@ -29,6 +29,29 @@ class MCPStatus(OpenCodeBaseModel): error: str | None = None +class AddMcpServerRequest(OpenCodeBaseModel): + """Request to add an MCP server dynamically. + + For stdio servers, provide ``command`` (and optionally ``args`` / ``env``). + For HTTP/SSE servers, provide ``url``. + """ + + name: str | None = None + """Name for the server (used as client_id).""" + + command: str | None = None + """Command to run (for stdio servers).""" + + args: list[str] | None = None + """Arguments for the command.""" + + url: str | None = None + """URL for HTTP/SSE servers.""" + + env: dict[str, str] | None = None + """Environment variables for the server.""" + + class McpAuthorizationResponse(OpenCodeBaseModel): """Response from starting MCP OAuth flow.""" diff --git a/src/agentpool_server/opencode_server/models/message.py b/src/opencode_sdk/models/message.py similarity index 78% rename from src/agentpool_server/opencode_server/models/message.py rename to src/opencode_sdk/models/message.py index 4f26066f8..e6174eff3 100644 --- a/src/agentpool_server/opencode_server/models/message.py +++ b/src/opencode_sdk/models/message.py @@ -2,22 +2,24 @@ from __future__ import annotations -from typing import Any, Literal, Self +from typing import Any, Literal from pydantic import Field from agentpool.utils import identifiers as identifier -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ( # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ( # noqa: TC001 + APIError, + APIErrorData, FileDiff, ModelRef, TextSpan, TimeCreated, Tokens, ) -from agentpool_server.opencode_server.models.parts import ( # noqa: TC001 +from opencode_sdk.models.inputs import PartInput # noqa: TC001 +from opencode_sdk.models.parts import ( # noqa: TC001 AgentPart, - APIErrorInfo, FilePart, FilePartSource, Part, @@ -32,6 +34,9 @@ ) +FinishReason = Literal["stop", "length", "content-filter", "tool-calls", "error", "unknown"] + + class MessageSummary(OpenCodeBaseModel): """Summary information for a message.""" @@ -71,25 +76,57 @@ class OutputFormatJsonSchema(OpenCodeBaseModel): OutputFormat = OutputFormatText | OutputFormatJsonSchema -class UserMessage(OpenCodeBaseModel): - """User message.""" +class BaseMessage(OpenCodeBaseModel): + """Base class for messages.""" id: str - role: Literal["user"] = "user" session_id: str - time: TimeCreated agent: str = "default" - model: ModelRef | None = None + variant: str | None = None + + +class UserMessage(BaseMessage): + """User message.""" + + role: Literal["user"] = "user" + time: TimeCreated + model: ModelRef format: OutputFormat | None = None summary: MessageSummary | None = None system: str | None = None tools: dict[str, bool] | None = None - variant: str | None = None + + +class AssistantMessage(BaseMessage): + """Assistant message.""" + + role: Literal["assistant"] = "assistant" + parent_id: str # Required - links to user message + model_id: str + provider_id: str + mode: str = "default" + path: MessagePath + time: MessageTime + tokens: Tokens = Field(default_factory=Tokens) + """Context window usage from the latest step. + + Replaced (not accumulated) on each step. The TUI shows this from the + last assistant message as the session "Context" indicator. + """ + cost: float = 0.0 + """Per-message cost in USD. + + The TUI sums this across all assistant messages for the session total, + so this must be per-message, not cumulative. + """ + error: MessageError | None = None + summary: bool | None = None + # Known values from AI SDK's LanguageModelV2FinishReason; schema allows any string + finish: FinishReason | str | None = None + structured: Any | None = None # --- Assistant message error types --- -# These match the NamedError pattern from upstream OpenCode: -# Each error is { name: Literal["..."], data: { ... } } class ProviderAuthErrorData(OpenCodeBaseModel): @@ -145,24 +182,6 @@ class MessageAbortedError(OpenCodeBaseModel): data: MessageAbortedErrorData -class APIErrorData(OpenCodeBaseModel): - """Data for API errors.""" - - message: str - status_code: int | None = None - is_retryable: bool = False - response_headers: dict[str, str] | None = None - response_body: str | None = None - metadata: dict[str, str] | None = None - - -class APIError(OpenCodeBaseModel): - """API error.""" - - name: Literal["APIError"] = Field(default="APIError", init=False) - data: APIErrorData - - class StructuredOutputErrorData(OpenCodeBaseModel): """Data for structured output errors.""" @@ -202,42 +221,13 @@ class ContextOverflowError(OpenCodeBaseModel): ) -class AssistantMessage(OpenCodeBaseModel): - """Assistant message.""" - - id: str - role: Literal["assistant"] = "assistant" - session_id: str - parent_id: str # Required - links to user message - model_id: str - provider_id: str - mode: str = "default" - agent: str = "default" - path: MessagePath - time: MessageTime - tokens: Tokens = Field(default_factory=Tokens) - """Context window usage from the latest step. - - Replaced (not accumulated) on each step. The TUI shows this from the - last assistant message as the session "Context" indicator. - """ - cost: float = 0.0 - """Per-message cost in USD. - - The TUI sums this across all assistant messages for the session total, - so this must be per-message, not cumulative. - """ - error: MessageError | None = None - summary: bool | None = None - finish: str | None = None - structured: Any | None = None - variant: str | None = None +MessageInfo = UserMessage | AssistantMessage -class MessageWithParts(OpenCodeBaseModel): - """Message with its parts.""" +class MessageWithParts[InfoT: MessageInfo = MessageInfo](OpenCodeBaseModel): + """Message with its parts, generic over the info type.""" - info: MessageInfo + info: InfoT parts: list[Part] = Field(default_factory=list) @classmethod @@ -247,8 +237,8 @@ def user( session_id: str, time: TimeCreated, agent_name: str, - model: ModelRef | None = None, - ) -> Self: + model: ModelRef, + ) -> MessageWithParts[UserMessage]: user_msg = UserMessage( id=message_id, session_id=session_id, @@ -256,7 +246,7 @@ def user( agent=agent_name, model=model, ) - return cls(info=user_msg) + return MessageWithParts(info=user_msg) @classmethod def assistant( @@ -272,10 +262,10 @@ def assistant( mode: str = "default", cost: float = 0.0, summary: bool | None = None, - finish: str | None = None, + finish: FinishReason | str | None = None, error: MessageError | None = None, tokens: Tokens | None = None, - ) -> Self: + ) -> MessageWithParts[AssistantMessage]: user_msg = AssistantMessage( id=message_id, session_id=session_id, @@ -292,7 +282,7 @@ def assistant( finish=finish, tokens=tokens or Tokens(), ) - return cls(info=user_msg) + return MessageWithParts(info=user_msg) def update_part(self, updated: Part) -> None: """Replace a part in the assistant message's parts list by ID.""" @@ -343,11 +333,7 @@ def add_file_part( self.parts.append(part) return part - def add_agent_part( - self, - name: str, - source: TextSpan | None = None, - ) -> AgentPart: + def add_agent_part(self, name: str, source: TextSpan | None = None) -> AgentPart: """Create and append an agent mention part.""" part = AgentPart( id=identifier.ascending("part"), @@ -410,12 +396,7 @@ def add_step_finish_part( self.parts.append(part) return part - def add_tool_part( - self, - tool: str, - call_id: str, - state: ToolState, - ) -> ToolPart: + def add_tool_part(self, tool: str, call_id: str, state: ToolState) -> ToolPart: """Create and append a tool call part.""" part = ToolPart( id=identifier.ascending("part"), @@ -437,69 +418,19 @@ def add_retry_part( metadata: dict[str, str] | None = None, ) -> RetryPart: """Create and append a retry part.""" + error = APIErrorData(message=message, is_retryable=is_retryable, metadata=metadata) part = RetryPart( id=identifier.ascending("part"), message_id=self.info.id, session_id=self.info.session_id, attempt=attempt, - error=APIErrorInfo( - message=message, - is_retryable=is_retryable, - metadata=metadata, - ), + error=APIError(data=error), time=TimeCreated(created=created), ) self.parts.append(part) return part -class TextPartInput(OpenCodeBaseModel): - """Text part for input.""" - - type: Literal["text"] = Field(default="text", init=False) - text: str - - -class FilePartInput(OpenCodeBaseModel): - """File part for input (image, document, etc.).""" - - type: Literal["file"] = Field(default="file", init=False) - mime: str - filename: str | None = None - url: str # Can be data: URI or file path - source: FilePartSource | None = None - - -class AgentPartInput(OpenCodeBaseModel): - """Agent mention part for input - references a sub-agent to delegate to. - - When a user types @agent-name in the prompt, this part is created. - """ - - type: Literal["agent"] = Field(default="agent", init=False) - name: str - """Name of the agent to delegate to.""" - source: TextSpan | None = None - """Source location in the original prompt text.""" - - -class SubtaskPartInput(OpenCodeBaseModel): - """Subtask part for input - spawns a subtask to another agent.""" - - type: Literal["subtask"] = Field(default="subtask", init=False) - prompt: str - """The prompt for the subtask.""" - description: str - """Description of what the subtask does.""" - agent: str - """The agent to handle this subtask.""" - model: ModelRef | None = None - """Optional model to use for the subtask.""" - - -PartInput = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput - - class MessageRequest(OpenCodeBaseModel): """Request body for sending a message.""" @@ -538,4 +469,4 @@ class CommandRequest(OpenCodeBaseModel): # Type unions -MessageInfo = UserMessage | AssistantMessage +AnyMessageWithParts = MessageWithParts[UserMessage] | MessageWithParts[AssistantMessage] diff --git a/src/agentpool_server/opencode_server/models/parts.py b/src/opencode_sdk/models/parts.py similarity index 93% rename from src/agentpool_server/opencode_server/models/parts.py rename to src/opencode_sdk/models/parts.py index c27fe7bed..9ea301d82 100644 --- a/src/agentpool_server/opencode_server/models/parts.py +++ b/src/opencode_sdk/models/parts.py @@ -7,8 +7,9 @@ from pydantic import Field from agentpool.utils.time_utils import now_ms -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ( +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ( + APIError, # noqa: TC001 ModelRef, # noqa: TC001 TextSpan, TimeCreated, # noqa: TC001 @@ -238,7 +239,7 @@ class ReasoningPart(PartBase): text: str """The reasoning/thinking content.""" metadata: dict[str, Any] | None = None - time: TimeStartEndOptional | None = None + time: TimeStartEndOptional class CompactionPart(PartBase): @@ -247,6 +248,8 @@ class CompactionPart(PartBase): type: Literal["compaction"] = Field(default="compaction", init=False) auto: bool = False """Whether this was an automatic compaction.""" + overflow: bool | None = None + """Whether this compaction was triggered by context overflow.""" class SubtaskPart(PartBase): @@ -265,24 +268,13 @@ class SubtaskPart(PartBase): """The model used for the subtask.""" -class APIErrorInfo(OpenCodeBaseModel): - """API error information for retry parts.""" - - message: str - status_code: int | None = None - is_retryable: bool = False - response_headers: dict[str, str] | None = None - response_body: str | None = None - metadata: dict[str, str] | None = None - - class RetryPart(PartBase): """Marks a retry of a failed operation.""" type: Literal["retry"] = Field(default="retry", init=False) attempt: int """Which retry attempt this is.""" - error: APIErrorInfo + error: APIError """Error information from the failed attempt.""" time: TimeCreated diff --git a/src/agentpool_server/opencode_server/models/provider.py b/src/opencode_sdk/models/provider.py similarity index 95% rename from src/agentpool_server/opencode_server/models/provider.py rename to src/opencode_sdk/models/provider.py index 8a21b6005..3391b901d 100644 --- a/src/agentpool_server/opencode_server/models/provider.py +++ b/src/opencode_sdk/models/provider.py @@ -6,8 +6,8 @@ from pydantic import Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ModelRef # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ModelRef # noqa: TC001 if TYPE_CHECKING: diff --git a/src/agentpool_server/opencode_server/models/pty.py b/src/opencode_sdk/models/pty.py similarity index 95% rename from src/agentpool_server/opencode_server/models/pty.py rename to src/opencode_sdk/models/pty.py index 619820654..7e75f56da 100644 --- a/src/agentpool_server/opencode_server/models/pty.py +++ b/src/opencode_sdk/models/pty.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel if TYPE_CHECKING: diff --git a/src/agentpool_server/opencode_server/models/question.py b/src/opencode_sdk/models/question.py similarity index 94% rename from src/agentpool_server/opencode_server/models/question.py rename to src/opencode_sdk/models/question.py index 121bd9c09..9348609ec 100644 --- a/src/agentpool_server/opencode_server/models/question.py +++ b/src/opencode_sdk/models/question.py @@ -4,7 +4,7 @@ from pydantic import Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel +from opencode_sdk.models.base import OpenCodeBaseModel class QuestionOption(OpenCodeBaseModel): diff --git a/src/agentpool_server/opencode_server/models/session.py b/src/opencode_sdk/models/session.py similarity index 75% rename from src/agentpool_server/opencode_server/models/session.py rename to src/opencode_sdk/models/session.py index d8d791847..0e2e0dfc9 100644 --- a/src/agentpool_server/opencode_server/models/session.py +++ b/src/opencode_sdk/models/session.py @@ -6,15 +6,14 @@ from pydantic import Field -from agentpool_server.opencode_server.models.base import OpenCodeBaseModel -from agentpool_server.opencode_server.models.common import ( # noqa: TC001 +from opencode_sdk.models.base import OpenCodeBaseModel +from opencode_sdk.models.common import ( # noqa: TC001 FileDiff, TimeCreatedUpdated, ) SessionStatusType = Literal["idle", "busy", "retry"] -TodoStatus = Literal["pending", "in_progress", "completed"] class SessionSummary(OpenCodeBaseModel): @@ -118,9 +117,32 @@ class SessionStatus(OpenCodeBaseModel): type: SessionStatusType = "idle" +TodoStatus = Literal["pending", "in_progress", "completed", "cancelled"] +"""Well-known todo status values used by OpenCode. + +The field accepts any string, but these are the conventional values: +- ``pending``: Task not yet started. +- ``in_progress``: Task currently being worked on. +- ``completed``: Task finished successfully. +- ``cancelled``: Task was cancelled. +""" + +TodoPriority = Literal["high", "medium", "low"] +"""Well-known todo priority values used by OpenCode. + +The field accepts any string, but these are the conventional values: +- ``high``: High priority. +- ``medium``: Medium priority (default). +- ``low``: Low priority. +""" + + class Todo(OpenCodeBaseModel): """Todo item for a session.""" - id: str content: str - status: TodoStatus = "pending" + """Brief description of the task.""" + status: TodoStatus | str = "pending" + """Current status of the task.""" + priority: TodoPriority | str = "medium" + """Priority level of the task.""" diff --git a/src/agentpool_server/opencode_server/models/tool_metadata.py b/src/opencode_sdk/models/tool_metadata.py similarity index 99% rename from src/agentpool_server/opencode_server/models/tool_metadata.py rename to src/opencode_sdk/models/tool_metadata.py index 9a1b62adb..593136d84 100644 --- a/src/agentpool_server/opencode_server/models/tool_metadata.py +++ b/src/opencode_sdk/models/tool_metadata.py @@ -19,7 +19,7 @@ from typing import Any, Literal, NotRequired, TypedDict -from agentpool_server.opencode_server.models.common import FileDiffStatus # noqa: TC001 +from opencode_sdk.models.common import FileDiffStatus # noqa: TC001 ChangeType = Literal["add", "update", "delete", "move"] diff --git a/src/codex_adapter/py.typed b/src/opencode_sdk/py.typed similarity index 100% rename from src/codex_adapter/py.typed rename to src/opencode_sdk/py.typed diff --git a/src/opencode_sdk/storage_client.py b/src/opencode_sdk/storage_client.py new file mode 100644 index 000000000..8f1d30fdb --- /dev/null +++ b/src/opencode_sdk/storage_client.py @@ -0,0 +1,325 @@ +"""Read-only client for OpenCode's SQLite database. + +Provides access to OpenCode's native SQLite format (>= 1.2), +returning only OpenCode SDK models (Session, MessageWithParts, etc.). + +The database is typically located at ~/.local/share/opencode/opencode.db. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +import sqlite3 +from typing import TYPE_CHECKING, Any + +import anyenv + +from agentpool.log import get_logger +from opencode_sdk.helpers import parse_message_info, parse_part +from opencode_sdk.models.message import MessageWithParts +from opencode_sdk.models.session import Session + + +if TYPE_CHECKING: + from opencode_sdk.models.message import MessageInfo + from opencode_sdk.models.parts import Part + + +logger = get_logger(__name__) + +DEFAULT_DB_PATH = "~/.local/share/opencode/opencode.db" + + +class OpenCodeStorageClient: + """Read-only client for OpenCode's SQLite database. + + All methods return OpenCode SDK models — no agentpool-specific types. + """ + + def __init__(self, db_path: str = DEFAULT_DB_PATH) -> None: + self.db_path = Path(db_path).expanduser() + + def _get_connection(self) -> sqlite3.Connection: + """Get a SQLite connection with row factory.""" + if not self.db_path.exists(): + raise FileNotFoundError(f"OpenCode database not found: {self.db_path}") + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + return conn + + # ── Sessions ────────────────────────────────────────────────────── + + def get_session(self, session_id: str) -> Session | None: + """Get a single session by ID.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return None + try: + row = conn.execute( + "SELECT id, project_id, parent_id, directory, title, version, " + "time_created, time_updated FROM session WHERE id = ?", + (session_id,), + ).fetchone() + if not row: + return None + return self._parse_session_row(row) + finally: + conn.close() + + def get_sessions( + self, + *, + since_ms: int | None = None, + limit: int | None = None, + ) -> list[Session]: + """Get sessions, optionally filtered by time and limited.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return [] + try: + conditions: list[str] = [] + params: list[Any] = [] + if since_ms is not None: + conditions.append("time_created >= ?") + params.append(since_ms) + + where = f" WHERE {' AND '.join(conditions)}" if conditions else "" + sql = ( + "SELECT id, project_id, parent_id, directory, title, version, " + f"time_created, time_updated FROM session{where} " + "ORDER BY time_updated DESC" + ) + if limit is not None: + sql += " LIMIT ?" + params.append(limit) + + return [self._parse_session_row(row) for row in conn.execute(sql, params).fetchall()] + finally: + conn.close() + + def get_session_title(self, session_id: str) -> str | None: + """Get the title of a session.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return None + try: + row = conn.execute( + "SELECT title FROM session WHERE id = ?", + (session_id,), + ).fetchone() + if row: + title: str = row["title"] + return title + return None + finally: + conn.close() + + def get_session_ids(self, name: str | None = None) -> list[str]: + """Get session IDs, optionally filtered by exact ID match.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return [] + try: + if name: + rows = conn.execute("SELECT id FROM session WHERE id = ?", (name,)).fetchall() + else: + rows = conn.execute("SELECT id FROM session").fetchall() + return [row["id"] for row in rows] + finally: + conn.close() + + def get_session_counts(self) -> tuple[int, int]: + """Get total count of sessions and messages.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return 0, 0 + try: + session_count: int = conn.execute("SELECT COUNT(*) FROM session").fetchone()[0] + msg_count: int = conn.execute("SELECT COUNT(*) FROM message").fetchone()[0] + return session_count, msg_count + finally: + conn.close() + + # ── Messages ────────────────────────────────────────────────────── + + def get_session_messages(self, session_id: str) -> list[MessageWithParts]: + """Get all messages with their parts for a session, ordered by time.""" + msg_rows = self._read_message_rows(session_id) + if not msg_rows: + return [] + parts_by_msg = self._read_parts_for_session(session_id) + result: list[MessageWithParts] = [] + for row in msg_rows: + msg_id: str = row["id"] + info = self._parse_message_row(row) + parts = parts_by_msg.get(msg_id, []) + result.append(MessageWithParts(info=info, parts=parts)) + return result + + def get_message(self, message_id: str) -> MessageWithParts | None: + """Get a single message with its parts by message ID.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return None + try: + row = conn.execute( + "SELECT id, session_id, time_created, time_updated, data FROM message WHERE id = ?", + (message_id,), + ).fetchone() + if not row: + return None + info = self._parse_message_row(row) + parts = self._read_parts_for_message(message_id) + return MessageWithParts(info=info, parts=parts) + finally: + conn.close() + + def get_messages_with_data( + self, + *, + since_ms: int | None = None, + ) -> list[MessageWithParts]: + """Get messages (with parts) across all sessions, optionally filtered by time. + + Used for stats queries that need to iterate all messages. + """ + try: + conn = self._get_connection() + except FileNotFoundError: + return [] + try: + if since_ms is not None: + cursor = conn.execute( + "SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data " + "FROM message m " + "JOIN session s ON m.session_id = s.id " + "WHERE s.time_created >= ?", + (since_ms,), + ) + else: + cursor = conn.execute( + "SELECT id, session_id, time_created, time_updated, data FROM message" + ) + results: list[MessageWithParts] = [] + for row in cursor: + info = self._parse_message_row(row) + # No parts loaded here — caller can load parts if needed + results.append(MessageWithParts(info=info)) + return results + finally: + conn.close() + + # ── Internal helpers ────────────────────────────────────────────── + + @staticmethod + def _parse_session_row(row: sqlite3.Row) -> Session: + """Parse a session DB row into a Session model.""" + from opencode_sdk.models.common import TimeCreatedUpdated + + return Session( + id=row["id"], + project_id=row["project_id"], + parent_id=row["parent_id"], + directory=row["directory"], + title=row["title"], + version=row["version"] if "version" in row else "1", # noqa: SIM401 + time=TimeCreatedUpdated( + created=row["time_created"], + updated=row["time_updated"], + ), + ) + + def _read_message_rows(self, session_id: str) -> list[sqlite3.Row]: + """Read all message rows for a session, ordered by time_created.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return [] + try: + cursor = conn.execute( + "SELECT id, session_id, time_created, time_updated, data " + "FROM message WHERE session_id = ? ORDER BY time_created ASC", + (session_id,), + ) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def _parse_message_row(row: sqlite3.Row) -> MessageInfo: + """Parse a message DB row into a MessageInfo model.""" + data: dict[str, Any] = anyenv.load_json(row["data"], return_type=dict) + return parse_message_info(data, message_id=row["id"], session_id=row["session_id"]) + + def _read_parts_for_session(self, session_id: str) -> dict[str, list[Part]]: + """Read all parts for a session, grouped by message_id.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return {} + try: + cursor = conn.execute( + "SELECT id, message_id, session_id, data " + "FROM part WHERE session_id = ? ORDER BY message_id, id ASC", + (session_id,), + ) + result: dict[str, list[Part]] = defaultdict(list) + for row in cursor: + data: dict[str, Any] = anyenv.load_json(row["data"], return_type=dict) + try: + part = parse_part( + data, + part_id=row["id"], + message_id=row["message_id"], + session_id=row["session_id"], + ) + result[row["message_id"]].append(part) + except Exception: # noqa: BLE001 + logger.debug( + "Failed to parse part, skipping", + part_id=row["id"], + part_type=data.get("type", "unknown"), + ) + return result + finally: + conn.close() + + def _read_parts_for_message(self, message_id: str) -> list[Part]: + """Read all parts for a single message, ordered by id.""" + try: + conn = self._get_connection() + except FileNotFoundError: + return [] + try: + cursor = conn.execute( + "SELECT id, message_id, session_id, data " + "FROM part WHERE message_id = ? ORDER BY id ASC", + (message_id,), + ) + parts: list[Part] = [] + for row in cursor: + data: dict[str, Any] = anyenv.load_json(row["data"], return_type=dict) + try: + part = parse_part( + data, + part_id=row["id"], + message_id=row["message_id"], + session_id=row["session_id"], + ) + parts.append(part) + except Exception: # noqa: BLE001 + logger.debug( + "Failed to parse part, skipping", + part_id=row["id"], + part_type=data.get("type", "unknown"), + ) + return parts + finally: + conn.close() diff --git a/src/pi_sdk/__init__.py b/src/pi_sdk/__init__.py new file mode 100644 index 000000000..e7300a455 --- /dev/null +++ b/src/pi_sdk/__init__.py @@ -0,0 +1,5 @@ +"""Pi SDK.""" + +from .client import RpcClient + +__all__ = ["RpcClient"] diff --git a/src/pi_sdk/client.py b/src/pi_sdk/client.py new file mode 100644 index 000000000..21a95e3d7 --- /dev/null +++ b/src/pi_sdk/client.py @@ -0,0 +1,499 @@ +"""Async Python RPC client for pi's coding agent. + +Spawns the agent in RPC mode and provides a typed async API for all operations. +Communication uses JSONL over stdin/stdout. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import contextlib +from dataclasses import dataclass, field +import json +import logging +from typing import TYPE_CHECKING, Any, Self + +from pi_sdk.models import ( + AgentMessageAdapter, + AgentSessionEvent, + AgentSessionEventAdapter, + BashResult, + CompactionResult, + CycleModelData, + CycleThinkingLevelData, + ExportHtmlData, + ForkData, + ForkMessageEntry, + LastAssistantTextData, + Model, + RpcSessionState, + RpcSlashCommand, + SessionStats, + SwitchSessionData, +) + + +if TYPE_CHECKING: + from pi_sdk.models import AgentMessage, ImageContent, SteeringMode, ThinkingLevel + + +logger = logging.getLogger(__name__) + + +class RpcError(Exception): + """Raised when the RPC agent returns an error response.""" + + +@dataclass +class RpcClientOptions: + """Configuration for the RPC client.""" + + cli_path: str = "pi" + cwd: str | None = None + env: dict[str, str] | None = None + provider: str | None = None + model: str | None = None + args: list[str] = field(default_factory=list) + + +EventListener = Callable[[AgentSessionEvent], None] + + +class RpcClient: + """Async RPC client for pi's coding agent. + + Usage:: + + async with RpcClient(RpcClientOptions(cwd="/my/project")) as client: + client.on_event(lambda e: print(e.type)) + events = await client.prompt_and_wait("Hello!") + for event in events: + match event: + case AgentEndEvent(messages=msgs): + print(f"Got {len(msgs)} messages") + """ + + def __init__(self, options: RpcClientOptions | None = None): + self._options = options or RpcClientOptions() + self._process: asyncio.subprocess.Process | None = None + self._event_listeners: list[EventListener] = [] + self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._request_id = 0 + self._stderr = "" + self._reader_task: asyncio.Task[None] | None = None + + # ========================================================================= + # Lifecycle + # ========================================================================= + + async def start(self) -> None: + """Start the RPC agent process.""" + if self._process is not None: + msg = "Client already started" + raise RuntimeError(msg) + + cmd_args = [self._options.cli_path, "--mode", "rpc"] + if self._options.provider: + cmd_args.extend(["--provider", self._options.provider]) + if self._options.model: + cmd_args.extend(["--model", self._options.model]) + cmd_args.extend(self._options.args) + + import os + + env = {**os.environ, **(self._options.env or {})} + + self._process = await asyncio.create_subprocess_exec( + *cmd_args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self._options.cwd, + env=env, + ) + + # Start background tasks for reading stdout and stderr + self._reader_task = asyncio.create_task(self._read_stdout()) + self._stderr_task = asyncio.create_task(self._read_stderr()) + + # Brief wait to detect immediate exit + await asyncio.sleep(0.1) + if self._process.returncode is not None: + msg = ( + f"Agent process exited immediately with code {self._process.returncode}. " + f"Stderr: {self._stderr}" + ) + raise RuntimeError(msg) + + async def stop(self) -> None: + """Stop the RPC agent process.""" + if self._process is None: + return + + self._process.terminate() + try: + await asyncio.wait_for(self._process.wait(), timeout=1.0) + except TimeoutError: + self._process.kill() + await self._process.wait() + + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader_task + + self._process = None + self._reader_task = None + + # Reject all pending requests + for fut in self._pending.values(): + if not fut.done(): + fut.set_exception(RpcError("Client stopped")) + self._pending.clear() + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.stop() + + # ========================================================================= + # Event subscription + # ========================================================================= + + def on_event(self, listener: EventListener) -> Callable[[], None]: + """Subscribe to typed agent events. Returns an unsubscribe callable.""" + self._event_listeners.append(listener) + + def unsubscribe() -> None: + with contextlib.suppress(ValueError): + self._event_listeners.remove(listener) + + return unsubscribe + + @property + def stderr(self) -> str: + """Collected stderr output (useful for debugging).""" + return self._stderr + + # ========================================================================= + # Command methods + # ========================================================================= + + async def prompt(self, message: str, images: list[ImageContent] | None = None) -> None: + """Send a prompt. Use on_event() or wait_for_idle() for results.""" + payload: dict[str, Any] = {"type": "prompt", "message": message} + if images: + payload["images"] = [img.model_dump(by_alias=True) for img in images] + await self._send(payload) + + async def steer(self, message: str, images: list[ImageContent] | None = None) -> None: + """Queue a steering message to interrupt the agent mid-run.""" + payload: dict[str, Any] = {"type": "steer", "message": message} + if images: + payload["images"] = [img.model_dump(by_alias=True) for img in images] + await self._send(payload) + + async def follow_up(self, message: str, images: list[ImageContent] | None = None) -> None: + """Queue a follow-up message processed after the agent finishes.""" + payload: dict[str, Any] = {"type": "follow_up", "message": message} + if images: + payload["images"] = [img.model_dump(by_alias=True) for img in images] + await self._send(payload) + + async def abort(self) -> None: + """Abort current operation.""" + await self._send({"type": "abort"}) + + async def new_session(self, parent_session: str | None = None) -> bool: + """Start a new session. Returns True if cancelled by extension.""" + payload: dict[str, Any] = {"type": "new_session"} + if parent_session: + payload["parentSession"] = parent_session + resp = await self._send(payload) + return bool(self._get_data(resp).get("cancelled", False)) + + async def get_state(self) -> RpcSessionState: + """Get current session state.""" + resp = await self._send({"type": "get_state"}) + return RpcSessionState.model_validate(self._get_data(resp)) + + async def set_model(self, provider: str, model_id: str) -> Model: + """Set model by provider and ID.""" + resp = await self._send({"type": "set_model", "provider": provider, "modelId": model_id}) + return Model.model_validate(self._get_data(resp)) + + async def cycle_model(self) -> CycleModelData | None: + """Cycle to next model. Returns None if only one model available.""" + resp = await self._send({"type": "cycle_model"}) + data = self._get_data(resp) + return CycleModelData.model_validate(data) if data else None + + async def get_available_models(self) -> list[Model]: + """Get list of available models.""" + resp = await self._send({"type": "get_available_models"}) + data = self._get_data(resp) + return [Model.model_validate(m) for m in data.get("models", [])] + + async def set_thinking_level(self, level: ThinkingLevel) -> None: + """Set thinking level.""" + await self._send({"type": "set_thinking_level", "level": level}) + + async def cycle_thinking_level(self) -> CycleThinkingLevelData | None: + """Cycle thinking level. Returns None if model doesn't support thinking.""" + resp = await self._send({"type": "cycle_thinking_level"}) + data = self._get_data(resp) + return CycleThinkingLevelData.model_validate(data) if data else None + + async def set_steering_mode(self, mode: SteeringMode) -> None: + """Set steering message mode.""" + await self._send({"type": "set_steering_mode", "mode": mode}) + + async def set_follow_up_mode(self, mode: SteeringMode) -> None: + """Set follow-up message mode.""" + await self._send({"type": "set_follow_up_mode", "mode": mode}) + + async def compact(self, custom_instructions: str | None = None) -> CompactionResult: + """Compact session context.""" + payload: dict[str, Any] = {"type": "compact"} + if custom_instructions: + payload["customInstructions"] = custom_instructions + resp = await self._send(payload) + return CompactionResult.model_validate(self._get_data(resp)) + + async def set_auto_compaction(self, enabled: bool) -> None: + """Set auto-compaction enabled/disabled.""" + await self._send({"type": "set_auto_compaction", "enabled": enabled}) + + async def set_auto_retry(self, enabled: bool) -> None: + """Set auto-retry enabled/disabled.""" + await self._send({"type": "set_auto_retry", "enabled": enabled}) + + async def abort_retry(self) -> None: + """Abort in-progress retry.""" + await self._send({"type": "abort_retry"}) + + async def bash(self, command: str) -> BashResult: + """Execute a bash command.""" + resp = await self._send({"type": "bash", "command": command}) + return BashResult.model_validate(self._get_data(resp)) + + async def abort_bash(self) -> None: + """Abort running bash command.""" + await self._send({"type": "abort_bash"}) + + async def get_session_stats(self) -> SessionStats: + """Get session statistics.""" + resp = await self._send({"type": "get_session_stats"}) + return SessionStats.model_validate(self._get_data(resp)) + + async def export_html(self, output_path: str | None = None) -> str: + """Export session to HTML. Returns the output file path.""" + payload: dict[str, Any] = {"type": "export_html"} + if output_path: + payload["outputPath"] = output_path + resp = await self._send(payload) + return ExportHtmlData.model_validate(self._get_data(resp)).path + + async def switch_session(self, session_path: str) -> bool: + """Switch to a different session file. Returns True if cancelled.""" + resp = await self._send({"type": "switch_session", "sessionPath": session_path}) + return SwitchSessionData.model_validate(self._get_data(resp)).cancelled + + async def fork(self, entry_id: str) -> ForkData: + """Fork from a specific message.""" + resp = await self._send({"type": "fork", "entryId": entry_id}) + return ForkData.model_validate(self._get_data(resp)) + + async def get_fork_messages(self) -> list[ForkMessageEntry]: + """Get messages available for forking.""" + resp = await self._send({"type": "get_fork_messages"}) + data = self._get_data(resp) + return [ForkMessageEntry.model_validate(m) for m in data.get("messages", [])] + + async def get_last_assistant_text(self) -> str | None: + """Get text of last assistant message.""" + resp = await self._send({"type": "get_last_assistant_text"}) + return LastAssistantTextData.model_validate(self._get_data(resp)).text + + async def set_session_name(self, name: str) -> None: + """Set the session display name.""" + await self._send({"type": "set_session_name", "name": name}) + + async def get_messages(self) -> list[AgentMessage]: + """Get all messages in the session, validated into typed models.""" + resp = await self._send({"type": "get_messages"}) + data = self._get_data(resp) + return [AgentMessageAdapter.validate_python(m) for m in data.get("messages", [])] + + async def get_commands(self) -> list[RpcSlashCommand]: + """Get available commands.""" + resp = await self._send({"type": "get_commands"}) + data = self._get_data(resp) + return [RpcSlashCommand.model_validate(c) for c in data.get("commands", [])] + + # ========================================================================= + # Helpers + # ========================================================================= + + async def wait_for_idle(self, timeout: float = 60.0) -> None: + """Wait for the agent to become idle (agent_end event).""" + fut: asyncio.Future[None] = asyncio.get_event_loop().create_future() + + def _listener(event: AgentSessionEvent) -> None: + if event.type == "agent_end" and not fut.done(): + fut.set_result(None) + + unsub = self.on_event(_listener) + try: + await asyncio.wait_for(fut, timeout=timeout) + except TimeoutError: + raise TimeoutError( + f"Timeout waiting for agent to become idle. Stderr: {self._stderr}" + ) from None + finally: + unsub() + + async def collect_events(self, timeout: float = 60.0) -> list[AgentSessionEvent]: + """Collect validated events until agent becomes idle.""" + events: list[AgentSessionEvent] = [] + fut: asyncio.Future[None] = asyncio.get_event_loop().create_future() + + def _listener(event: AgentSessionEvent) -> None: + events.append(event) + if event.type == "agent_end" and not fut.done(): + fut.set_result(None) + + unsub = self.on_event(_listener) + try: + await asyncio.wait_for(fut, timeout=timeout) + except TimeoutError: + raise TimeoutError(f"Timeout collecting events. Stderr: {self._stderr}") from None + finally: + unsub() + return events + + async def prompt_and_wait( + self, + message: str, + images: list[ImageContent] | None = None, + timeout: float = 60.0, + ) -> list[AgentSessionEvent]: + """Send a prompt and wait for completion, returning all validated events.""" + collect_task = asyncio.create_task(self.collect_events(timeout)) + await self.prompt(message, images) + return await collect_task + + # ========================================================================= + # Internal + # ========================================================================= + + async def _read_stdout(self) -> None: + """Background task: read JSONL lines from stdout and dispatch.""" + assert self._process + assert self._process.stdout + reader = self._process.stdout + while True: + line = await reader.readline() + if not line: + break + self._handle_line(line.decode("utf-8", errors="replace").strip()) + + async def _read_stderr(self) -> None: + """Background task: accumulate stderr for debugging.""" + assert self._process + assert self._process.stderr + reader = self._process.stderr + while True: + chunk = await reader.read(4096) + if not chunk: + break + self._stderr += chunk.decode("utf-8", errors="replace") + + def _handle_line(self, line: str) -> None: + if not line: + return + try: + data = json.loads(line) + except json.JSONDecodeError: + logger.debug("Ignoring non-JSON line: %s", line[:200]) + return + + # Check if it's a response to a pending request + if data.get("type") == "response" and data.get("id") in self._pending: + fut = self._pending.pop(data["id"]) + if not fut.done(): + fut.set_result(data) + return + + # Otherwise it's an event — validate into typed model and notify listeners + try: + event = AgentSessionEventAdapter.validate_python(data) + except Exception: # noqa: BLE001 + logger.debug("Could not validate event: %s", data.get("type")) + return + + for listener in self._event_listeners: + try: + listener(event) + except Exception: + logger.exception("Event listener error") + + async def _send(self, command: dict[str, Any]) -> dict[str, Any]: + """Send a command and wait for its response.""" + if not self._process or not self._process.stdin: + msg = "Client not started" + raise RuntimeError(msg) + + self._request_id += 1 + req_id = f"req_{self._request_id}" + command["id"] = req_id + + loop = asyncio.get_event_loop() + fut: asyncio.Future[dict[str, Any]] = loop.create_future() + self._pending[req_id] = fut + + payload = json.dumps(command, separators=(",", ":")) + "\n" + self._process.stdin.write(payload.encode("utf-8")) + await self._process.stdin.drain() + + try: + return await asyncio.wait_for(fut, timeout=30.0) + except TimeoutError: + self._pending.pop(req_id, None) + raise TimeoutError( + f"Timeout waiting for response to {command.get('type')}. Stderr: {self._stderr}" + ) from None + + @staticmethod + def _get_data(response: dict[str, Any]) -> Any: + """Extract data from a successful response, or raise on error.""" + if not response.get("success"): + raise RpcError(response.get("error", "Unknown RPC error")) + return response.get("data") or {} + + +if __name__ == "__main__": + + async def main() -> None: + opts = RpcClientOptions(cwd="/tmp") + async with RpcClient(opts) as client: + print("Client started successfully") + + # Get session state + state = await client.get_state() + print(f"Session state: {state}") + + # Send a simple prompt and collect events + print("Sending prompt...") + events = await client.prompt_and_wait("Say hello in one sentence.", timeout=30.0) + for ev in events: + print(f" Event: {ev.type}") + + # Get last assistant text + text = await client.get_last_assistant_text() + print(f"Assistant said: {text}") + + asyncio.run(main()) diff --git a/src/pi_sdk/models.py b/src/pi_sdk/models.py new file mode 100644 index 000000000..3ab3a4c42 --- /dev/null +++ b/src/pi_sdk/models.py @@ -0,0 +1,1166 @@ +"""Pydantic models for a Python stdio client to pi's RPC interface. + +Based on: +- packages/ai/src/types.ts (core LLM types) +- packages/agent/src/types.ts (agent loop types) +- packages/coding-agent/src/modes/rpc/rpc-client.ts (RPC client API) +- packages/coding-agent/src/core/agent-session.ts (session types) +""" + +from __future__ import annotations + +import sys +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Discriminator, Tag, TypeAdapter +from pydantic.alias_generators import to_camel + + +IS_DEV = "pytest" in sys.modules + + +# ============================================================================= +# Base Model +# ============================================================================= + + +class PiBaseModel(BaseModel): + """Base model for all pi RPC Pydantic models. + + Automatically generates camelCase aliases from snake_case field names, + matching the TypeScript/JSON wire format. + """ + + model_config = ConfigDict( + populate_by_name=True, + alias_generator=to_camel, + extra="forbid" if IS_DEV else "ignore", + defer_build=True, + use_attribute_docstrings=True, + ) + + +# ============================================================================= +# Enums / Literal Types +# ============================================================================= + +KnownApi = Literal[ + "openai-completions", + "mistral-conversations", + "openai-responses", + "azure-openai-responses", + "openai-codex-responses", + "anthropic-messages", + "bedrock-converse-stream", + "google-generative-ai", + "google-gemini-cli", + "google-vertex", +] + +Api = str # KnownApi | arbitrary string + +KnownProvider = Literal[ + "amazon-bedrock", + "anthropic", + "google", + "google-gemini-cli", + "google-antigravity", + "google-vertex", + "openai", + "azure-openai-responses", + "openai-codex", + "github-copilot", + "xai", + "groq", + "cerebras", + "openrouter", + "vercel-ai-gateway", + "zai", + "mistral", + "minimax", + "minimax-cn", + "huggingface", + "opencode", + "opencode-go", + "kimi-coding", +] + +Provider = str # KnownProvider | arbitrary string + +StopReason = Literal["stop", "length", "toolUse", "error", "aborted"] + +ThinkingLevel = Literal["off", "minimal", "low", "medium", "high", "xhigh"] + +CacheRetention = Literal["none", "short", "long"] + +Transport = Literal["sse", "websocket", "auto"] + +ToolExecutionMode = Literal["sequential", "parallel"] + +InputType = Literal["text", "image"] + +SteeringMode = Literal["all", "one-at-a-time"] + + +# ============================================================================= +# Content Types +# ============================================================================= + + +class TextContent(PiBaseModel): + """Text content.""" + + type: Literal["text"] = "text" + text: str + text_signature: str | None = None + + +class ThinkingContent(PiBaseModel): + """Thinking content.""" + + type: Literal["thinking"] = "thinking" + thinking: str + thinking_signature: str | None = None + redacted: bool | None = None + + +class ImageContent(PiBaseModel): + """Image content.""" + + type: Literal["image"] = "image" + data: str # base64 encoded + mime_type: str + + +class ToolCall(PiBaseModel): + """Tool call content.""" + + type: Literal["toolCall"] = "toolCall" + id: str + name: str + arguments: dict[str, Any] + + +# ============================================================================= +# Usage / Cost +# ============================================================================= + + +class UsageCost(PiBaseModel): + """Usage cost.""" + + input: float + output: float + cache_read: float + cache_write: float + total: float + + +class Usage(PiBaseModel): + """Usage.""" + + input: int + output: int + cache_read: int + cache_write: int + total_tokens: int + cost: UsageCost + + +# ============================================================================= +# Messages +# ============================================================================= + + +class UserMessage(PiBaseModel): + """User message.""" + + role: Literal["user"] = "user" + content: str | list[TextContent | ImageContent] + timestamp: int + """Unix timestamp in milliseconds.""" + + +class AssistantMessage(PiBaseModel): + """Assistant message.""" + + role: Literal["assistant"] = "assistant" + content: list[TextContent | ThinkingContent | ToolCall] + api: str + provider: str + model: str + response_id: str | None = None + usage: Usage + stop_reason: StopReason + error_message: str | None = None + timestamp: int + + +class ToolResultMessage(PiBaseModel): + """Tool result message.""" + + role: Literal["toolResult"] = "toolResult" + tool_call_id: str + tool_name: str + content: list[TextContent | ImageContent] + details: Any | None = None + is_error: bool + timestamp: int + + +Message = Annotated[ + Annotated[UserMessage, Tag("user")] + | Annotated[AssistantMessage, Tag("assistant")] + | Annotated[ToolResultMessage, Tag("toolResult")], + Discriminator("role"), +] + + +# ============================================================================= +# Custom Messages (coding-agent specific) +# ============================================================================= + + +class BashExecutionMessage(PiBaseModel): + """Bash execution message.""" + + role: Literal["bashExecution"] = "bashExecution" + command: str + output: str + exit_code: int + cancelled: bool + truncated: bool + full_output_path: str | None = None + timestamp: int + exclude_from_context: bool | None = None + + +class CustomMessage(PiBaseModel): + """Custom message.""" + + role: Literal["custom"] = "custom" + custom_type: str + content: Any + display: Any | None = None + details: Any | None = None + timestamp: int + + +AgentMessage = Annotated[ + Annotated[UserMessage, Tag("user")] + | Annotated[AssistantMessage, Tag("assistant")] + | Annotated[ToolResultMessage, Tag("toolResult")] + | Annotated[BashExecutionMessage, Tag("bashExecution")] + | Annotated[CustomMessage, Tag("custom")], + Discriminator("role"), +] + +AgentMessageAdapter: TypeAdapter[AgentMessage] = TypeAdapter(AgentMessage) + + +# ============================================================================= +# Tool Definition +# ============================================================================= + + +class Tool(PiBaseModel): + """Tool definition.""" + + name: str + description: str + parameters: dict[str, Any] + """JSON Schema (TypeBox TSchema).""" + + +# ============================================================================= +# Model +# ============================================================================= + + +class ModelCost(PiBaseModel): + """Model cost per million tokens.""" + + input: float + """$/million tokens.""" + output: float + cache_read: float + cache_write: float + + +class Model(PiBaseModel): + """Model configuration.""" + + id: str + name: str + api: str + provider: str + base_url: str + reasoning: bool + input: list[InputType] + cost: ModelCost + context_window: int + max_tokens: int + headers: dict[str, str] | None = None + compat: dict[str, Any] | None = None + + +# ============================================================================= +# Assistant Message Events (streaming protocol) +# ============================================================================= + + +class EventStart(PiBaseModel): + """Stream start event.""" + + type: Literal["start"] = "start" + partial: AssistantMessage + + +class EventTextStart(PiBaseModel): + """Text content start event.""" + + type: Literal["text_start"] = "text_start" + content_index: int + partial: AssistantMessage + + +class EventTextDelta(PiBaseModel): + """Text content delta event.""" + + type: Literal["text_delta"] = "text_delta" + content_index: int + delta: str + partial: AssistantMessage + + +class EventTextEnd(PiBaseModel): + """Text content end event.""" + + type: Literal["text_end"] = "text_end" + content_index: int + content: str + partial: AssistantMessage + + +class EventThinkingStart(PiBaseModel): + """Thinking content start event.""" + + type: Literal["thinking_start"] = "thinking_start" + content_index: int + partial: AssistantMessage + + +class EventThinkingDelta(PiBaseModel): + """Thinking content delta event.""" + + type: Literal["thinking_delta"] = "thinking_delta" + content_index: int + delta: str + partial: AssistantMessage + + +class EventThinkingEnd(PiBaseModel): + """Thinking content end event.""" + + type: Literal["thinking_end"] = "thinking_end" + content_index: int + content: str + partial: AssistantMessage + + +class EventToolcallStart(PiBaseModel): + """Tool call start event.""" + + type: Literal["toolcall_start"] = "toolcall_start" + content_index: int + partial: AssistantMessage + + +class EventToolcallDelta(PiBaseModel): + """Tool call delta event.""" + + type: Literal["toolcall_delta"] = "toolcall_delta" + content_index: int + delta: str + partial: AssistantMessage + + +class EventToolcallEnd(PiBaseModel): + """Tool call end event.""" + + type: Literal["toolcall_end"] = "toolcall_end" + content_index: int + tool_call: ToolCall + partial: AssistantMessage + + +class EventDone(PiBaseModel): + """Stream done event.""" + + type: Literal["done"] = "done" + reason: Literal["stop", "length", "toolUse"] + message: AssistantMessage + + +class EventError(PiBaseModel): + """Stream error event.""" + + type: Literal["error"] = "error" + reason: Literal["error", "aborted"] + error: AssistantMessage + + +AssistantMessageEvent = Annotated[ + Annotated[EventStart, Tag("start")] + | Annotated[EventTextStart, Tag("text_start")] + | Annotated[EventTextDelta, Tag("text_delta")] + | Annotated[EventTextEnd, Tag("text_end")] + | Annotated[EventThinkingStart, Tag("thinking_start")] + | Annotated[EventThinkingDelta, Tag("thinking_delta")] + | Annotated[EventThinkingEnd, Tag("thinking_end")] + | Annotated[EventToolcallStart, Tag("toolcall_start")] + | Annotated[EventToolcallDelta, Tag("toolcall_delta")] + | Annotated[EventToolcallEnd, Tag("toolcall_end")] + | Annotated[EventDone, Tag("done")] + | Annotated[EventError, Tag("error")], + Discriminator("type"), +] + + +# ============================================================================= +# Agent Events (emitted over RPC) +# ============================================================================= + + +class AgentStartEvent(PiBaseModel): + """Agent start event.""" + + type: Literal["agent_start"] = "agent_start" + + +class AgentEndEvent(PiBaseModel): + """Agent end event.""" + + type: Literal["agent_end"] = "agent_end" + messages: list[AgentMessage] + + +class TurnStartEvent(PiBaseModel): + """Turn start event.""" + + type: Literal["turn_start"] = "turn_start" + + +class TurnEndEvent(PiBaseModel): + """Turn end event.""" + + type: Literal["turn_end"] = "turn_end" + message: AgentMessage + tool_results: list[ToolResultMessage] + + +class MessageStartEvent(PiBaseModel): + """Message start event.""" + + type: Literal["message_start"] = "message_start" + message: AgentMessage + + +class MessageUpdateEvent(PiBaseModel): + """Message update event.""" + + type: Literal["message_update"] = "message_update" + message: AgentMessage + assistant_message_event: AssistantMessageEvent + + +class MessageEndEvent(PiBaseModel): + """Message end event.""" + + type: Literal["message_end"] = "message_end" + message: AgentMessage + + +class ToolExecutionStartEvent(PiBaseModel): + """Tool execution start event.""" + + type: Literal["tool_execution_start"] = "tool_execution_start" + tool_call_id: str + tool_name: str + args: Any + + +class ToolExecutionUpdateEvent(PiBaseModel): + """Tool execution update event.""" + + type: Literal["tool_execution_update"] = "tool_execution_update" + tool_call_id: str + tool_name: str + args: Any + partial_result: Any + + +class ToolExecutionEndEvent(PiBaseModel): + """Tool execution end event.""" + + type: Literal["tool_execution_end"] = "tool_execution_end" + tool_call_id: str + tool_name: str + result: Any + is_error: bool + + +AgentEvent = Annotated[ + Annotated[AgentStartEvent, Tag("agent_start")] + | Annotated[AgentEndEvent, Tag("agent_end")] + | Annotated[TurnStartEvent, Tag("turn_start")] + | Annotated[TurnEndEvent, Tag("turn_end")] + | Annotated[MessageStartEvent, Tag("message_start")] + | Annotated[MessageUpdateEvent, Tag("message_update")] + | Annotated[MessageEndEvent, Tag("message_end")] + | Annotated[ToolExecutionStartEvent, Tag("tool_execution_start")] + | Annotated[ToolExecutionUpdateEvent, Tag("tool_execution_update")] + | Annotated[ToolExecutionEndEvent, Tag("tool_execution_end")], + Discriminator("type"), +] + +AgentEventAdapter: TypeAdapter[AgentEvent] = TypeAdapter(AgentEvent) + + +# ============================================================================= +# Session-specific Events (AgentSessionEvent extends AgentEvent) +# ============================================================================= + + +class QueueUpdateEvent(PiBaseModel): + """Queue update event.""" + + type: Literal["queue_update"] = "queue_update" + steering: list[str] + follow_up: list[str] + + +class CompactionStartEvent(PiBaseModel): + """Compaction start event.""" + + type: Literal["compaction_start"] = "compaction_start" + reason: Literal["manual", "threshold", "overflow"] + + +class CompactionEndEvent(PiBaseModel): + """Compaction end event.""" + + type: Literal["compaction_end"] = "compaction_end" + reason: Literal["manual", "threshold", "overflow"] + result: CompactionResult | None = None + aborted: bool + will_retry: bool + error_message: str | None = None + + +class AutoRetryStartEvent(PiBaseModel): + """Auto-retry start event.""" + + type: Literal["auto_retry_start"] = "auto_retry_start" + attempt: int + max_attempts: int + delay_ms: int + error_message: str + + +class AutoRetryEndEvent(PiBaseModel): + """Auto-retry end event.""" + + type: Literal["auto_retry_end"] = "auto_retry_end" + success: bool + attempt: int + final_error: str | None = None + + +AgentSessionEvent = Annotated[ + Annotated[AgentStartEvent, Tag("agent_start_s")] + | Annotated[AgentEndEvent, Tag("agent_end_s")] + | Annotated[TurnStartEvent, Tag("turn_start_s")] + | Annotated[TurnEndEvent, Tag("turn_end_s")] + | Annotated[MessageStartEvent, Tag("message_start_s")] + | Annotated[MessageUpdateEvent, Tag("message_update_s")] + | Annotated[MessageEndEvent, Tag("message_end_s")] + | Annotated[ToolExecutionStartEvent, Tag("tool_execution_start_s")] + | Annotated[ToolExecutionUpdateEvent, Tag("tool_execution_update_s")] + | Annotated[ToolExecutionEndEvent, Tag("tool_execution_end_s")] + | Annotated[QueueUpdateEvent, Tag("queue_update")] + | Annotated[CompactionStartEvent, Tag("compaction_start")] + | Annotated[CompactionEndEvent, Tag("compaction_end")] + | Annotated[AutoRetryStartEvent, Tag("auto_retry_start")] + | Annotated[AutoRetryEndEvent, Tag("auto_retry_end")], + Discriminator("type"), +] + +AgentSessionEventAdapter: TypeAdapter[AgentSessionEvent] = TypeAdapter(AgentSessionEvent) + + +# ============================================================================= +# RPC Session State +# ============================================================================= + + +class ContextUsage(PiBaseModel): + """Context window usage.""" + + tokens: int | None + context_window: int + percent: float | None + + +class RpcSessionState(PiBaseModel): + """RPC session state.""" + + model: Model | None = None + thinking_level: ThinkingLevel + is_streaming: bool + is_compacting: bool + steering_mode: SteeringMode + follow_up_mode: SteeringMode + session_file: str | None = None + session_id: str + session_name: str | None = None + auto_compaction_enabled: bool + message_count: int + pending_message_count: int + + +# ============================================================================= +# Session Stats +# ============================================================================= + + +class TokenStats(PiBaseModel): + """Token statistics.""" + + input: int + output: int + cache_read: int + cache_write: int + total: int + + +SourceScope = Literal["user", "project", "temporary"] +SourceOrigin = Literal["package", "top-level"] + + +class SourceInfo(PiBaseModel): + """Source info for extension/resource origin.""" + + path: str + source: str + scope: SourceScope + origin: SourceOrigin + base_dir: str | None = None + + +class SessionStats(PiBaseModel): + """Session statistics.""" + + session_file: str | None = None + session_id: str + user_messages: int + assistant_messages: int + tool_calls: int + tool_results: int + total_messages: int + tokens: TokenStats + cost: float + context_usage: ContextUsage | None = None + + +# ============================================================================= +# Bash Result +# ============================================================================= + + +class BashResult(PiBaseModel): + """Bash command result.""" + + output: str + exit_code: int | None = None + cancelled: bool = False + truncated: bool = False + full_output_path: str | None = None + + +# ============================================================================= +# Compaction Result +# ============================================================================= + + +class CompactionResult(PiBaseModel): + """Compaction result.""" + + summary: str + first_kept_entry_id: str + tokens_before: int + details: Any | None = None + + +# ============================================================================= +# Model Info (from getAvailableModels) +# ============================================================================= + + +# ModelInfo is just Model — get_available_models returns full Model objects +ModelInfo = Model + + +# ============================================================================= +# RPC Slash Command +# ============================================================================= + + +class RpcSlashCommand(PiBaseModel): + """RPC slash command.""" + + name: str + description: str | None = None + source: Literal["extension", "prompt", "skill"] + source_info: SourceInfo + + +# ============================================================================= +# RPC Commands (client -> agent via stdin) +# ============================================================================= + + +class RpcPromptCommand(PiBaseModel): + """Prompt command.""" + + type: Literal["prompt"] = "prompt" + id: str + message: str + images: list[ImageContent] | None = None + streaming_behavior: Literal["steer", "followUp"] | None = None + + +class RpcSteerCommand(PiBaseModel): + """Steer command.""" + + type: Literal["steer"] = "steer" + id: str + message: str + images: list[ImageContent] | None = None + + +class RpcFollowUpCommand(PiBaseModel): + """Follow-up command.""" + + type: Literal["follow_up"] = "follow_up" + id: str + message: str + images: list[ImageContent] | None = None + + +class RpcAbortCommand(PiBaseModel): + """Abort command.""" + + type: Literal["abort"] = "abort" + id: str + + +class RpcNewSessionCommand(PiBaseModel): + """New session command.""" + + type: Literal["new_session"] = "new_session" + id: str + parent_session: str | None = None + + +class RpcGetStateCommand(PiBaseModel): + """Get state command.""" + + type: Literal["get_state"] = "get_state" + id: str + + +class RpcSetModelCommand(PiBaseModel): + """Set model command.""" + + type: Literal["set_model"] = "set_model" + id: str + provider: str + model_id: str + + +class RpcCycleModelCommand(PiBaseModel): + """Cycle model command.""" + + type: Literal["cycle_model"] = "cycle_model" + id: str + + +class RpcGetAvailableModelsCommand(PiBaseModel): + """Get available models command.""" + + type: Literal["get_available_models"] = "get_available_models" + id: str + + +class RpcSetThinkingLevelCommand(PiBaseModel): + """Set thinking level command.""" + + type: Literal["set_thinking_level"] = "set_thinking_level" + id: str + level: ThinkingLevel + + +class RpcCycleThinkingLevelCommand(PiBaseModel): + """Cycle thinking level command.""" + + type: Literal["cycle_thinking_level"] = "cycle_thinking_level" + id: str + + +class RpcSetSteeringModeCommand(PiBaseModel): + """Set steering mode command.""" + + type: Literal["set_steering_mode"] = "set_steering_mode" + id: str + mode: SteeringMode + + +class RpcSetFollowUpModeCommand(PiBaseModel): + """Set follow-up mode command.""" + + type: Literal["set_follow_up_mode"] = "set_follow_up_mode" + id: str + mode: SteeringMode + + +class RpcCompactCommand(PiBaseModel): + """Compact command.""" + + type: Literal["compact"] = "compact" + id: str + custom_instructions: str | None = None + + +class RpcSetAutoCompactionCommand(PiBaseModel): + """Set auto-compaction command.""" + + type: Literal["set_auto_compaction"] = "set_auto_compaction" + id: str + enabled: bool + + +class RpcSetAutoRetryCommand(PiBaseModel): + """Set auto-retry command.""" + + type: Literal["set_auto_retry"] = "set_auto_retry" + id: str + enabled: bool + + +class RpcAbortRetryCommand(PiBaseModel): + """Abort retry command.""" + + type: Literal["abort_retry"] = "abort_retry" + id: str + + +class RpcBashCommand(PiBaseModel): + """Bash command.""" + + type: Literal["bash"] = "bash" + id: str + command: str + + +class RpcAbortBashCommand(PiBaseModel): + """Abort bash command.""" + + type: Literal["abort_bash"] = "abort_bash" + id: str + + +class RpcGetSessionStatsCommand(PiBaseModel): + """Get session stats command.""" + + type: Literal["get_session_stats"] = "get_session_stats" + id: str + + +class RpcExportHtmlCommand(PiBaseModel): + """Export HTML command.""" + + type: Literal["export_html"] = "export_html" + id: str + output_path: str | None = None + + +class RpcSwitchSessionCommand(PiBaseModel): + """Switch session command.""" + + type: Literal["switch_session"] = "switch_session" + id: str + session_path: str + + +class RpcForkCommand(PiBaseModel): + """Fork command.""" + + type: Literal["fork"] = "fork" + id: str + entry_id: str + + +class RpcGetForkMessagesCommand(PiBaseModel): + """Get fork messages command.""" + + type: Literal["get_fork_messages"] = "get_fork_messages" + id: str + + +class RpcGetLastAssistantTextCommand(PiBaseModel): + """Get last assistant text command.""" + + type: Literal["get_last_assistant_text"] = "get_last_assistant_text" + id: str + + +class RpcSetSessionNameCommand(PiBaseModel): + """Set session name command.""" + + type: Literal["set_session_name"] = "set_session_name" + id: str + name: str + + +class RpcGetMessagesCommand(PiBaseModel): + """Get messages command.""" + + type: Literal["get_messages"] = "get_messages" + id: str + + +class RpcGetCommandsCommand(PiBaseModel): + """Get commands command.""" + + type: Literal["get_commands"] = "get_commands" + id: str + + +RpcCommand = ( + RpcPromptCommand + | RpcSteerCommand + | RpcFollowUpCommand + | RpcAbortCommand + | RpcNewSessionCommand + | RpcGetStateCommand + | RpcSetModelCommand + | RpcCycleModelCommand + | RpcGetAvailableModelsCommand + | RpcSetThinkingLevelCommand + | RpcCycleThinkingLevelCommand + | RpcSetSteeringModeCommand + | RpcSetFollowUpModeCommand + | RpcCompactCommand + | RpcSetAutoCompactionCommand + | RpcSetAutoRetryCommand + | RpcAbortRetryCommand + | RpcBashCommand + | RpcAbortBashCommand + | RpcGetSessionStatsCommand + | RpcExportHtmlCommand + | RpcSwitchSessionCommand + | RpcForkCommand + | RpcGetForkMessagesCommand + | RpcGetLastAssistantTextCommand + | RpcSetSessionNameCommand + | RpcGetMessagesCommand + | RpcGetCommandsCommand +) + + +# ============================================================================= +# RPC Responses (agent -> client via stdout) +# ============================================================================= + + +class RpcSuccessResponse(PiBaseModel): + """RPC success response.""" + + type: Literal["response"] = "response" + id: str + success: Literal[True] = True + data: Any | None = None + + +class RpcErrorResponse(PiBaseModel): + """RPC error response.""" + + type: Literal["response"] = "response" + id: str + success: Literal[False] = False + error: str + + +RpcResponse = RpcSuccessResponse | RpcErrorResponse + + +# ============================================================================= +# Specific Response Data Types (returned inside RpcSuccessResponse.data) +# ============================================================================= + + +class NewSessionData(PiBaseModel): + """New session response data.""" + + cancelled: bool + + +# set_model returns a full Model object +SetModelData = Model + + +class CycleModelData(PiBaseModel): + """Cycle model response data.""" + + model: Model + thinking_level: ThinkingLevel + is_scoped: bool + + +class AvailableModelsData(PiBaseModel): + """Available models response data.""" + + models: list[ModelInfo] + + +class CycleThinkingLevelData(PiBaseModel): + """Cycle thinking level response data.""" + + level: ThinkingLevel + + +class ExportHtmlData(PiBaseModel): + """Export HTML response data.""" + + path: str + + +class SwitchSessionData(PiBaseModel): + """Switch session response data.""" + + cancelled: bool + + +class ForkMessageEntry(PiBaseModel): + """Fork message entry.""" + + entry_id: str + text: str + + +class ForkData(PiBaseModel): + """Fork response data.""" + + text: str + cancelled: bool + + +class ForkMessagesData(PiBaseModel): + """Fork messages response data.""" + + messages: list[ForkMessageEntry] + + +class LastAssistantTextData(PiBaseModel): + """Last assistant text response data.""" + + text: str | None + + +class MessagesData(PiBaseModel): + """Messages response data.""" + + messages: list[AgentMessage] + + +class CommandsData(PiBaseModel): + """Commands data.""" + + commands: list[RpcSlashCommand] + + +class SessionStatsData(PiBaseModel): + """Wraps SessionStats in the RPC response.""" + + stats: SessionStats + + +# ============================================================================= +# Extension UI Events (agent -> client via stdout) +# ============================================================================= + +ExtensionUIMethod = Literal[ + "select", + "confirm", + "input", + "editor", + "notify", + "setStatus", + "setWidget", + "setTitle", + "set_editor_text", +] + + +class ExtensionUIRequest(PiBaseModel): + """Extension UI request event emitted when an extension needs user input. + + Fields vary by method. All methods include type, id, and method. + """ + + type: Literal["extension_ui_request"] = "extension_ui_request" + id: str + method: ExtensionUIMethod + title: str | None = None + message: str | None = None + options: list[str] | None = None + placeholder: str | None = None + prefill: str | None = None + timeout: int | None = None + notify_type: Literal["info", "warning", "error"] | None = None + status_key: str | None = None + status_text: str | None = None + widget_key: str | None = None + widget_lines: list[str] | None = None + widget_placement: Literal["aboveEditor", "belowEditor"] | None = None + text: str | None = None + + +class ExtensionUIResponseValue(PiBaseModel): + """Extension UI response with a string value (select/input/editor).""" + + type: Literal["extension_ui_response"] = "extension_ui_response" + id: str + value: str + + +class ExtensionUIResponseConfirm(PiBaseModel): + """Extension UI response for confirm dialogs.""" + + type: Literal["extension_ui_response"] = "extension_ui_response" + id: str + confirmed: bool + + +class ExtensionUIResponseCancel(PiBaseModel): + """Extension UI response for cancelled requests.""" + + type: Literal["extension_ui_response"] = "extension_ui_response" + id: str + cancelled: Literal[True] = True + + +ExtensionUIResponse = ( + ExtensionUIResponseValue | ExtensionUIResponseConfirm | ExtensionUIResponseCancel +) diff --git a/src/pi_sdk/py.typed b/src/pi_sdk/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/tests/agents/acp_agent/test_acp_converters.py b/tests/agents/acp_agent/test_acp_converters.py index 424b90913..74126c21d 100644 --- a/tests/agents/acp_agent/test_acp_converters.py +++ b/tests/agents/acp_agent/test_acp_converters.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic_ai import ( ModelRequest, ModelResponse, @@ -26,6 +28,10 @@ ) +if TYPE_CHECKING: + from acp.schema import SessionUpdate + + class TestACPMessageAccumulator: """Tests for ACPMessageAccumulator class.""" @@ -220,7 +226,10 @@ def test_process_all_updates(self) -> None: """process_all handles list of updates.""" accumulator = ACPMessageAccumulator() - updates = [UserMessageChunk.text("User"), AgentMessageChunk.text("Agent")] + updates: list[SessionUpdate] = [ + UserMessageChunk.text("User"), + AgentMessageChunk.text("Agent"), + ] for item in updates: accumulator.process(item) messages = accumulator.finalize() @@ -247,7 +256,10 @@ class TestACPNotificationsToMessages: def test_basic_conversion(self) -> None: """Basic conversion of notifications to messages.""" - updates = [UserMessageChunk.text("Hi"), AgentMessageChunk.text("Hello")] + updates: list[SessionUpdate] = [ + UserMessageChunk.text("Hi"), + AgentMessageChunk.text("Hello"), + ] messages = acp_notifications_to_messages(updates) diff --git a/tests/agents/claude_code_agent/test_metadata_converter.py b/tests/agents/claude_code_agent/test_metadata_converter.py index 831f8d629..aed2dac78 100644 --- a/tests/agents/claude_code_agent/test_metadata_converter.py +++ b/tests/agents/claude_code_agent/test_metadata_converter.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import cast +from typing import Any, cast from clawd_code_sdk.models import BashInput, BashOutput, TodoItem, TodoWriteOutput import pytest from agentpool.agents.claude_code_agent.converters import convert_to_opencode_metadata -from agentpool_server.opencode_server.models.tool_metadata import ( +from opencode_sdk.models.tool_metadata import ( BashMetadata, EditMetadata, ReadMetadata, @@ -22,7 +22,7 @@ class TestConvertToolResultToOpencodeMetadata: def test_write_tool_result(self) -> None: """Test conversion of Write tool result.""" - sdk_result = { + sdk_result: dict[str, Any] = { "type": "create", "filePath": "/tmp/test/hello.py", "content": "def hello():\n print('Hello')\n", @@ -164,7 +164,7 @@ def test_unknown_tool(self) -> None: def test_case_insensitive_tool_name(self) -> None: """Test that tool name matching is case-insensitive.""" - sdk_result = { + sdk_result: dict[str, Any] = { "type": "create", "filePath": "/tmp/test.py", "content": "# test", @@ -177,24 +177,6 @@ def test_case_insensitive_tool_name(self) -> None: assert convert_to_opencode_metadata("WRITE", sdk_result) is not None assert convert_to_opencode_metadata("Write", sdk_result) is not None - def test_edit_with_missing_original_file(self) -> None: - """Test Edit conversion when originalFile is None.""" - sdk_result = { - "filePath": "/tmp/test.py", - "oldString": "old", - "newString": "new", - "originalFile": None, # Can happen in some edge cases - "structuredPatch": [], - "userModified": False, - "replaceAll": False, - } - metadata = convert_to_opencode_metadata("Edit", sdk_result) - assert metadata is not None - metadata = cast(EditMetadata, metadata) - assert metadata["filediff"]["before"] == "" - # after is empty string when we can't compute it without originalFile - assert metadata["filediff"]["after"] == "" - def test_write_without_content_still_succeeds(self) -> None: """Test Write conversion without content still succeeds (filepath is enough).""" sdk_result = {"filePath": "/tmp/test.py"} @@ -263,7 +245,7 @@ def test_todowrite_empty_todos(self) -> None: oldTodos=[TodoItem(content="old", status="completed", activeForm="")], newTodos=[] ) metadata = convert_to_opencode_metadata("TodoWrite", sdk_result) - assert metadata == {"todos": []} + assert metadata == TodoMetadata(todos=[]) def test_todowrite_case_insensitive(self) -> None: """Test that tool name matching is case-insensitive.""" diff --git a/tests/agents/codex_agent/test_codex_toolset_integration.py b/tests/agents/codex_agent/test_codex_toolset_integration.py index 76232e746..a7c5ca49d 100644 --- a/tests/agents/codex_agent/test_codex_toolset_integration.py +++ b/tests/agents/codex_agent/test_codex_toolset_integration.py @@ -27,6 +27,7 @@ pytest.skip("codex CLI not available", allow_module_level=True) pytestmark = [pytest.mark.integration] +DEFAULT_MODEL = "gpt-5.1-codex-mini" @pytest.fixture @@ -35,7 +36,7 @@ def codex_config_with_subagent() -> CodexAgentConfig: return CodexAgentConfig( name="codex_orchestrator", description="Codex agent with subagent delegation capabilities", - model="gpt-5.1-codex-mini", + model=DEFAULT_MODEL, reasoning_effort="medium", approval_policy="never", tools=[SubagentToolsetConfig()], @@ -82,7 +83,7 @@ async def test_codex_subagent_tool_invocation(): # Create a unique config for this test to avoid conflicts with other tests config = CodexAgentConfig( name="codex_tool_invoker", # Unique name - model="gpt-5.1-codex-mini", + model=DEFAULT_MODEL, reasoning_effort="medium", approval_policy="never", tools=[SubagentToolsetConfig()], @@ -106,7 +107,7 @@ async def test_codex_multiple_toolsets(): """Test CodexAgent with multiple toolsets.""" config = CodexAgentConfig( name="codex_multi", - model="gpt-5.1-codex-mini", + model=DEFAULT_MODEL, reasoning_effort="medium", approval_policy="never", tools=[SubagentToolsetConfig(), SkillsToolsetConfig()], @@ -132,7 +133,7 @@ async def test_codex_mcp_servers_config(): test_server_path = Path(__file__).parent.parent.parent / "mcp_server" / "server.py" config = CodexAgentConfig( name="codex_mixed", - model="gpt-5.1-codex-mini", + model=DEFAULT_MODEL, reasoning_effort="medium", approval_policy="never", tools=[SubagentToolsetConfig()], diff --git a/tests/hooks/test_hooks.py b/tests/hooks/test_hooks.py index b742c274b..92d3c959d 100644 --- a/tests/hooks/test_hooks.py +++ b/tests/hooks/test_hooks.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest @@ -15,7 +15,7 @@ # Hook state for testing -hook_state: dict[str, list] = {"calls": [], "results": []} +hook_state: dict[str, list[Any]] = {"calls": [], "results": []} def reset_hook_state(): @@ -98,9 +98,10 @@ async def test_post_run_hook(): await agent.run("Hello") assert len(hook_state["results"]) == 1 - assert "Hello" in str(hook_state["results"][0]["prompt"]) - assert hook_state["results"][0]["result"] is not None - assert hook_state["results"][0]["event"] == "post_run" + result = hook_state["results"][0] + assert "Hello" in str(result["prompt"]) + assert result["result"] is not None + assert result["event"] == "post_run" # Tests for pre_tool_use hooks diff --git a/tests/integration/test_permission_denial_sync.py b/tests/integration/test_permission_denial_sync.py index ac952787b..e0fb71da6 100644 --- a/tests/integration/test_permission_denial_sync.py +++ b/tests/integration/test_permission_denial_sync.py @@ -23,11 +23,13 @@ from acp.schema import ToolCallStart from agentpool.agents.claude_code_agent import ClaudeCodeAgent +from agentpool.ui.base import InputProvider from agentpool_server.acp_server.event_converter import ACPEventConverter if TYPE_CHECKING: from agentpool import AgentContext + from agentpool.agents.context import ConfirmationResult @dataclass @@ -46,7 +48,7 @@ def log_permission_request(self, tool_name: str, tool_call_id: str) -> None: self.permission_requests.append({"tool_name": tool_name, "tool_call_id": tool_call_id}) -class DenyingInputProvider: +class DenyingInputProvider(InputProvider): """Input provider that denies all tool calls.""" def __init__(self, trace: EventTrace, delay: float = 0.1): @@ -58,7 +60,7 @@ async def get_tool_confirmation( self, context: AgentContext[Any], tool_description: str = "", - ) -> str: + ) -> ConfirmationResult: """Deny all tool calls after a small delay.""" tool_name = context.tool_name or "unknown" tool_call_id = context.tool_call_id or "unknown" @@ -67,7 +69,7 @@ async def get_tool_confirmation( self.denial_count += 1 return "skip" - async def elicit_input(self, *args: Any, **kwargs: Any) -> Any: + async def get_elicitation(self, *args: Any, **kwargs: Any) -> Any: """Not used in this test.""" return ElicitResult(action="cancel") @@ -94,10 +96,7 @@ async def test_tool_call_event_ordering(): # Track events per tool_call_id tool_call_events = defaultdict[str, list[str]](list) - async with ClaudeCodeAgent( - name="test-agent", - permission_mode="default", - ) as agent: + async with ClaudeCodeAgent(name="test-agent", permission_mode="default") as agent: prompt = ( "Create a file at /tmp/test_event_order.txt with content 'hello'. " "Don't retry if denied." diff --git a/tests/manifest/test_models.py b/tests/manifest/test_models.py index e4fd7e680..7d9c0728a 100644 --- a/tests/manifest/test_models.py +++ b/tests/manifest/test_models.py @@ -73,7 +73,7 @@ def test_valid_agent_definition(): agent_def = AgentsManifest.model_validate(yamling.load_yaml(VALID_AGENT_CONFIG)) schema = agent_def.responses["TestResponse"].response_schema assert isinstance(schema, InlineSchemaDef) - score = schema.fields["score"] # pyright: ignore + score = schema.fields["score"] assert score.ge == 0 assert score.le == 100 diff --git a/tests/mcp_server/server.py b/tests/mcp_server/server.py index f2d4eb1c3..21aa961aa 100644 --- a/tests/mcp_server/server.py +++ b/tests/mcp_server/server.py @@ -5,7 +5,7 @@ import anyio from fastmcp import Context, FastMCP -from fastmcp.tools.tool import ToolResult +from fastmcp.tools import ToolResult from fastmcp.utilities.types import Audio, File, Image from mcp.types import ModelHint, ModelPreferences, TextContent from pydantic import BaseModel diff --git a/tests/mcp_server/test_tool_bridge.py b/tests/mcp_server/test_tool_bridge.py index e07a4f830..6ea43cf68 100644 --- a/tests/mcp_server/test_tool_bridge.py +++ b/tests/mcp_server/test_tool_bridge.py @@ -3,10 +3,10 @@ from __future__ import annotations import shutil -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from clawd_code_sdk import ClaudeAgentOptions, ClaudeSDKClient, tool -from clawd_code_sdk.models import ResultMessage +from clawd_code_sdk.models import McpHttpServerConfig, ResultMessage from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client import pytest @@ -214,7 +214,7 @@ async def test_claude_code_passes_tool_use_id_in_meta(): """ @tool(name="capture_meta", description="Captures the _meta field", input_schema={"value": int}) - async def capture_meta_tool(input_data: dict) -> dict: + async def capture_meta_tool(input_data: dict[str, Any]) -> dict[str, Any]: """Tool that captures what _meta was passed.""" # The _meta is not directly accessible here in SDK tools # We need a different approach - use our MCP bridge @@ -250,7 +250,7 @@ async def capture_context_tool(ctx: AgentContext, number: int) -> str: async with ToolManagerBridge(node=agent) as bridge: options = ClaudeAgentOptions( - mcp_servers={"test_bridge": {"type": "http", "url": bridge.url}}, + mcp_servers={"test_bridge": McpHttpServerConfig(url=bridge.url)}, allowed_tools=["mcp__test_bridge__capture_context_tool"], ) diff --git a/tests/messaging/test_compaction.py b/tests/messaging/test_compaction.py index b9e3d6691..bbf1b4476 100644 --- a/tests/messaging/test_compaction.py +++ b/tests/messaging/test_compaction.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic_ai import ( ModelRequest, ModelResponse, @@ -38,6 +40,10 @@ ) +if TYPE_CHECKING: + from pydantic_ai import ModelMessage + + @pytest.fixture def sample_messages() -> list[ModelRequest | ModelResponse]: """Create a sample conversation for testing.""" @@ -98,7 +104,7 @@ async def test_filter_thinking(sample_messages): async def test_filter_retry_prompts(): """Test that retry prompts are filtered out.""" - messages = [ + messages: list[ModelMessage] = [ ModelRequest( parts=[ UserPromptPart(content="Do something"), @@ -150,7 +156,7 @@ async def test_filter_tool_calls_include_only(messages_with_tools): async def test_filter_empty_messages(): """Test that empty messages are removed.""" - messages = [ + messages: list[ModelMessage] = [ ModelRequest(parts=[UserPromptPart(content="Hello")]), ModelResponse(parts=[TextPart(content="")]), # Empty text ModelRequest(parts=[UserPromptPart(content="World")]), @@ -234,7 +240,7 @@ async def test_keep_first_and_last(sample_messages): async def test_when_message_count_exceeds(): """Test conditional step application.""" - messages = [ + messages: list[ModelMessage] = [ ModelRequest(parts=[UserPromptPart(content="1")]), ModelResponse(parts=[TextPart(content="1")]), ] diff --git a/tests/messaging/test_connection_registry.py b/tests/messaging/test_connection_registry.py index ec4a0e959..6785bb7bf 100644 --- a/tests/messaging/test_connection_registry.py +++ b/tests/messaging/test_connection_registry.py @@ -1,11 +1,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic_ai.models.test import TestModel import pytest from agentpool import Agent, AgentPool +if TYPE_CHECKING: + from agentpool.talk import Talk + + @pytest.fixture async def pool(): """Create agent pool with test agents.""" @@ -24,7 +30,7 @@ async def pool(): async def test_registry_captures_agent_interaction(pool: AgentPool): """Test that registry captures real agent interactions.""" - messages = [] + messages: list[Talk.ConnectionProcessed] = [] pool.connection_registry.message_flow.connect(messages.append) # Get agents and set up connection @@ -41,7 +47,7 @@ async def test_registry_captures_agent_interaction(pool: AgentPool): async def test_chained_communication(pool: AgentPool): """Test message flow through chain of agents.""" - messages = [] + messages: list[Talk.ConnectionProcessed] = [] pool.connection_registry.message_flow.connect(messages.append) # Set up chain: agent1 -> agent2 -> agent3 @@ -66,7 +72,7 @@ async def test_chained_communication(pool: AgentPool): async def test_broadcast_communication(pool: AgentPool): """Test broadcasting to multiple agents.""" - messages = [] + messages: list[Talk.ConnectionProcessed] = [] pool.connection_registry.message_flow.connect(messages.append) # Set up broadcast: agent1 -> [agent2, agent3] diff --git a/tests/servers/acp_server/__snapshots__/test_acp_via_acp_snapshots/TestExecuteCommandViaACP.test_execute_command_simple.json b/tests/servers/acp_server/__snapshots__/test_acp_via_acp_snapshots/TestExecuteCommandViaACP.test_execute_command_simple.json index 1a2538f7e..5991c3005 100644 --- a/tests/servers/acp_server/__snapshots__/test_acp_via_acp_snapshots/TestExecuteCommandViaACP.test_execute_command_simple.json +++ b/tests/servers/acp_server/__snapshots__/test_acp_via_acp_snapshots/TestExecuteCommandViaACP.test_execute_command_simple.json @@ -2,6 +2,7 @@ { "content": [], "event_kind": "tool_call_start", + "field_meta": null, "kind": "execute", "locations": [], "raw_input": { @@ -14,6 +15,7 @@ }, { "event_kind": "tool_call_progress", + "field_meta": null, "items": [ { "terminal_id": "cmd_0001", @@ -33,6 +35,7 @@ }, { "event_kind": "tool_call_progress", + "field_meta": null, "items": [ { "terminal_id": "cmd_0001", @@ -52,6 +55,7 @@ }, { "event_kind": "tool_call_progress", + "field_meta": null, "items": [ { "terminal_id": "cmd_0001", diff --git a/tests/servers/acp_server/conftest.py b/tests/servers/acp_server/conftest.py index 719856674..6970b04b0 100644 --- a/tests/servers/acp_server/conftest.py +++ b/tests/servers/acp_server/conftest.py @@ -6,7 +6,7 @@ import pytest -from acp import ClientCapabilities, DefaultACPClient, FileSystemCapability +from acp import ClientCapabilities, DefaultACPClient, FileSystemCapabilities from acp.agent.implementations import TestAgent from agentpool import Agent from agentpool.delegation import AgentPool @@ -60,7 +60,7 @@ def default_test_agent(mock_agent_pool_with_agent: tuple[AgentPool, Agent]) -> A @pytest.fixture def client_capabilities(): """Create client capabilities for testing.""" - fs_caps = FileSystemCapability(read_text_file=True, write_text_file=True) + fs_caps = FileSystemCapabilities(read_text_file=True, write_text_file=True) return ClientCapabilities(fs=fs_caps, terminal=True) diff --git a/tests/servers/acp_server/test_acp_via_acp_snapshots.py b/tests/servers/acp_server/test_acp_via_acp_snapshots.py index 1b453038d..9611c6db6 100644 --- a/tests/servers/acp_server/test_acp_via_acp_snapshots.py +++ b/tests/servers/acp_server/test_acp_via_acp_snapshots.py @@ -143,7 +143,7 @@ async def execute_tool( # Extract mock environment from first tool (all should have same env) mock_env = None for tool in tools: - if hasattr(tool, "environment") and (env := tool.environment): # pyright: ignore[reportAttributeAccessIssue] + if hasattr(tool, "environment") and (env := tool.environment): mock_env = env break if not mock_env: diff --git a/tests/servers/acp_server/test_command_bridge_streaming.py b/tests/servers/acp_server/test_command_bridge_streaming.py index 9ca6505c1..fe472367a 100644 --- a/tests/servers/acp_server/test_command_bridge_streaming.py +++ b/tests/servers/acp_server/test_command_bridge_streaming.py @@ -55,7 +55,7 @@ async def capture_message( ): sent_messages.append(message) - session.notifications.send_agent_text = capture_message # type: ignore[method-assign] + session.notifications.send_agent_text = capture_message # type: ignore[method-assign] # ty:ignore[invalid-assignment] await session.execute_slash_command("/help") assert len(sent_messages) > 0 @@ -104,7 +104,7 @@ async def capture_with_time(message): current_time = time.perf_counter() messages_with_time.append((message, current_time - start_time)) - session.notifications.send_agent_text = capture_with_time # type: ignore[method-assign, assignment] + session.notifications.send_agent_text = capture_with_time # type: ignore[method-assign, assignment] # ty:ignore[invalid-assignment] await session.execute_slash_command("/slow") # Verify we got multiple messages min_expected_messages = 3 @@ -173,7 +173,7 @@ async def capture_message( ): sent_messages.append(message) - session.notifications.send_agent_text = capture_message # type: ignore[method-assign] + session.notifications.send_agent_text = capture_message # type: ignore[method-assign] # ty:ignore[invalid-assignment] # Execute failing command await session.execute_slash_command("/fail") diff --git a/tests/servers/acp_server/test_process_tools_integration.py b/tests/servers/acp_server/test_process_tools_integration.py index d33a678b6..a058d0887 100644 --- a/tests/servers/acp_server/test_process_tools_integration.py +++ b/tests/servers/acp_server/test_process_tools_integration.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from anyenv.process_manager.models import ProcessOutput from exxec import MockExecutionEnvironment @@ -19,9 +19,9 @@ from agentpool.agents.events import RichAgentStreamEvent -def drain_event_queue(agent: Agent) -> list[RichAgentStreamEvent]: +def drain_event_queue(agent: Agent) -> list[RichAgentStreamEvent[Any]]: """Drain all events from the agent's event queue.""" - events: list[RichAgentStreamEvent] = [] + events: list[RichAgentStreamEvent[Any]] = [] while not agent._event_queue.empty(): try: events.append(agent._event_queue.get_nowait()) diff --git a/tests/servers/acp_server/test_rpc.py b/tests/servers/acp_server/test_rpc.py index eb9e1143b..baabfae27 100644 --- a/tests/servers/acp_server/test_rpc.py +++ b/tests/servers/acp_server/test_rpc.py @@ -234,10 +234,10 @@ async def test_session_notifications_flow( # Agent -> Client notifications agent_chunk = AgentMessageChunk.text("Hello") agent_notification = SessionNotification(session_id="sess", update=agent_chunk) - await client_conn.session_update(agent_notification) # pyright: ignore[reportArgumentType] + await client_conn.session_update(agent_notification) chunk = UserMessageChunk.text("World") user_notification = SessionNotification(session_id="sess", update=chunk) - await client_conn.session_update(user_notification) # pyright: ignore[reportArgumentType] + await client_conn.session_update(user_notification) # Wait for async dispatch for _ in range(50): diff --git a/tests/servers/acp_server/test_tool_call_snapshots.py b/tests/servers/acp_server/test_tool_call_snapshots.py index c2230d57b..1a53f3df7 100644 --- a/tests/servers/acp_server/test_tool_call_snapshots.py +++ b/tests/servers/acp_server/test_tool_call_snapshots.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: - from syrupy import SnapshotAssertion + from syrupy.assertion import SnapshotAssertion @pytest.fixture diff --git a/tests/servers/opencode_server/conftest.py b/tests/servers/opencode_server/conftest.py index 54e3cc1bd..e7b9c56da 100644 --- a/tests/servers/opencode_server/conftest.py +++ b/tests/servers/opencode_server/conftest.py @@ -23,15 +23,15 @@ from agentpool.models.manifest import AgentsManifest from agentpool.storage import StorageManager -from agentpool.utils.streams import FileOpsTracker +from agentpool.utils.file_ops_tracker import FileOpsTracker from agentpool.utils.time_utils import now_ms from agentpool.utils.todos import TodoTracker from agentpool_server.opencode_server.dependencies import get_state -from agentpool_server.opencode_server.models import Session -from agentpool_server.opencode_server.models.common import TimeCreatedUpdated from agentpool_server.opencode_server.routes import file_router, session_router from agentpool_server.opencode_server.state import ServerState from agentpool_storage.memory_provider.provider import MemoryStorageProvider +from opencode_sdk.models import Session +from opencode_sdk.models.common import TimeCreatedUpdated if TYPE_CHECKING: @@ -261,7 +261,7 @@ async def capturing_broadcast(event: Any) -> None: await capture.capture(event) await original_broadcast(event) - server_state.broadcast_event = capturing_broadcast # type: ignore[method-assign] + server_state.broadcast_event = capturing_broadcast # type: ignore[method-assign] # ty:ignore[invalid-assignment] return capture diff --git a/tests/servers/opencode_server/test_reasoning.py b/tests/servers/opencode_server/test_reasoning.py index 4b59d7efb..22823eeb9 100644 --- a/tests/servers/opencode_server/test_reasoning.py +++ b/tests/servers/opencode_server/test_reasoning.py @@ -1,26 +1,21 @@ -from typing import cast +from __future__ import annotations + from unittest.mock import MagicMock from agentpool.agents.events import PartDeltaEvent, PartStartEvent -from agentpool_server.opencode_server.models import PartUpdatedEvent -from agentpool_server.opencode_server.models.events import PartUpdatedEventProperties -from agentpool_server.opencode_server.models.parts import ReasoningPart from agentpool_server.opencode_server.stream_adapter import OpenCodeStreamAdapter +from opencode_sdk.models import PartUpdatedEvent, PartUpdatedEventProperties, ReasoningPart def test_thinking_events_create_reasoning_part(): """Verify ThinkingPart/ThinkingPartDelta events create ReasoningPart.""" # Create a mock MessageWithParts mock_msg = MagicMock() + mock_msg.info.id = "msg-1" + mock_msg.info.session_id = "session-1" mock_msg.parts = [] - adapter = OpenCodeStreamAdapter( - session_id="test-session", - assistant_msg_id="msg-1", - assistant_msg=mock_msg, - working_dir=".", - ) - + adapter = OpenCodeStreamAdapter(assistant_msg=mock_msg, working_dir=".") # Use the adapter's _handle_event method directly events = list(adapter._handle_event(PartStartEvent.thinking(index=0, content="Thinking..."))) events.extend(list(adapter._handle_event(PartDeltaEvent.thinking(index=0, content=" more...")))) @@ -34,8 +29,9 @@ def test_thinking_events_create_reasoning_part(): reasoning_events.append(e) assert len(reasoning_events) >= 1, "ReasoningPart should be created from thinking events" - # Cast to narrow type since we've already checked it's a ReasoningPart - first_part = cast(ReasoningPart, reasoning_events[0].properties.part) - last_part = cast(ReasoningPart, reasoning_events[-1].properties.part) + first_part = reasoning_events[0].properties.part + last_part = reasoning_events[-1].properties.part + assert isinstance(first_part, ReasoningPart) + assert isinstance(last_part, ReasoningPart) assert "Thinking..." in first_part.text assert " more..." in last_part.text diff --git a/tests/servers/opencode_server/test_session_lifecycle.py b/tests/servers/opencode_server/test_session_lifecycle.py index 4e61f0965..14b7223ba 100644 --- a/tests/servers/opencode_server/test_session_lifecycle.py +++ b/tests/servers/opencode_server/test_session_lifecycle.py @@ -16,8 +16,7 @@ from typing import TYPE_CHECKING from agentpool.sessions.models import SessionData -from agentpool_server.opencode_server.models import SessionStatus -from agentpool_server.opencode_server.models.events import SessionCreatedEvent +from opencode_sdk.models import SessionCreatedEvent, SessionStatus if TYPE_CHECKING: @@ -243,7 +242,7 @@ async def test_list_sessions_returns_created_sessions( ) for i, sid in enumerate(session_ids) ] - server_state.agent.list_sessions.return_value = session_data_list # ty: ignore[unresolved-attribute] + server_state.agent.list_sessions.return_value = session_data_list # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] # List sessions response = await async_client.get("/session") assert response.status_code == 200 diff --git a/tests/test_agent.py b/tests/test_agent.py index 634e52c5e..75ebe4db3 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -147,7 +147,7 @@ async def test_cost_tracking_with_real_model(): # Verify cost info is present and non-zero assert result.cost_info is not None, "cost_info should not be None" assert result.cost_info.total_cost > 0, "total_cost should be greater than zero" - assert result.cost_info.token_usage.total_tokens > 0, "total_tokens should be > 0" + assert result.usage.total_tokens > 0, "total_tokens should be > 0" if __name__ == "__main__": diff --git a/tests/test_agui_agent.py b/tests/test_agui_agent.py index 3ed36711e..9ab4e6dc5 100644 --- a/tests/test_agui_agent.py +++ b/tests/test_agui_agent.py @@ -124,7 +124,7 @@ async def test_agui_agent_run(mock_sse_response): def test_agui_to_native_event_text_content(): """Test conversion of text content events.""" event = TextMessageContentEvent(message_id="msg1", delta="Test content") - native = agui_to_native_event(event) + native = next(agui_to_native_event(event)) assert native is not None assert isinstance(native, PartDeltaEvent) @@ -132,7 +132,7 @@ def test_agui_to_native_event_text_content(): def test_agui_to_native_event_tool_call(): """Test conversion of tool call events.""" event = ToolCallStartEvent(tool_call_id="call1", tool_call_name="test_tool") - native = agui_to_native_event(event) + native = next(agui_to_native_event(event)) assert native is not None assert isinstance(native, NativeToolCallStart) assert native.tool_call_id == "call1" diff --git a/tests/test_agui_agent_startup.py b/tests/test_agui_agent_startup.py index c56c3ca48..49b5117b1 100644 --- a/tests/test_agui_agent_startup.py +++ b/tests/test_agui_agent_startup.py @@ -7,6 +7,7 @@ import pytest from agentpool.agents.agui_agent import AGUIAgent +from agentpool.agents.events import StreamCompleteEvent @pytest.mark.skipif(sys.platform == "win32", reason="Hangs on Windows CI") @@ -48,7 +49,9 @@ async def test_agui_agent_streaming_with_managed_server(): events.append(event) # noqa: PERF401 assert len(events) > 0 # Last event should be StreamCompleteEvent with final message - assert events[-1].message.content + complete_event = events[-1] + assert isinstance(complete_event, StreamCompleteEvent) + assert complete_event.message.content @pytest.mark.skipif(sys.platform == "win32", reason="Hangs on Windows CI") diff --git a/tests/test_codex_adapter.py b/tests/test_codex_adapter.py deleted file mode 100644 index f48a7b60f..000000000 --- a/tests/test_codex_adapter.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for Codex adapter.""" - -from __future__ import annotations - -import asyncio - -from pydantic import ValidationError -import pytest - -from codex_adapter import CodexClient, HttpMcpServer, StdioMcpServer -from codex_adapter.client import _mcp_config_to_toml_inline -from codex_adapter.exceptions import CodexProcessError, CodexRequestError -from codex_adapter.models.events import ( - AgentMessageDeltaEvent, - get_text_delta, - is_completed_event, - is_delta_event, - parse_codex_event, -) - - -def test_parse_codex_event_camel_to_snake(): - """Test camelCase JSON is converted to snake_case fields.""" - params = {"delta": "Hello", "itemId": "123", "threadId": "t1", "turnId": "u1"} - event = parse_codex_event("item/agentMessage/delta", params) - assert isinstance(event, AgentMessageDeltaEvent) - assert event.data.item_id == "123" # camelCase -> snake_case - assert event.data.thread_id == "t1" - - -def test_parse_codex_event_unknown_type_raises(): - """Unknown event types raise ValidationError (strict mode).""" - with pytest.raises(ValidationError): - parse_codex_event("unknown/event/type", {"threadId": "t1"}) - - -def test_parse_codex_event_legacy_v1_returns_none(): - """Legacy codex/event/* methods are filtered out.""" - assert parse_codex_event("codex/event/task_started", {"id": "0"}) is None - - -def test_event_helper_functions(): - """Test is_delta_event, is_completed_event, get_text_delta.""" - params = {"delta": "text", "itemId": "1", "threadId": "t", "turnId": "u"} - delta = parse_codex_event("item/agentMessage/delta", params) - params = {"threadId": "t", "turn": {"id": "u", "status": "completed", "items": []}} - completed = parse_codex_event("turn/completed", params) - assert delta - assert completed - assert is_delta_event(delta) is True - assert is_completed_event(delta) is False - assert get_text_delta(delta) == "text" - assert is_delta_event(completed) is False - assert is_completed_event(completed) is True - assert get_text_delta(completed) == "" - - -async def test_process_message_routes_response_to_future(): - """JSON-RPC responses are routed to pending request futures.""" - client = CodexClient() - future: asyncio.Future[dict] = asyncio.Future() - client._pending_requests[1] = future - result = {"threadId": "thread-123"} - await client._process_message({"jsonrpc": "2.0", "id": 1, "result": result}) - assert future.result() == {"threadId": "thread-123"} - - -async def test_process_message_error_raises(): - """JSON-RPC error responses set exception on future.""" - client = CodexClient() - future: asyncio.Future[dict] = asyncio.Future() - client._pending_requests[1] = future - error = {"code": -32602, "message": "Invalid params"} - await client._process_message({"jsonrpc": "2.0", "id": 1, "error": error}) - with pytest.raises(CodexRequestError) as exc: - future.result() - assert exc.value.code == -32602 - - -async def test_process_message_notification_queued(): - """JSON-RPC notifications are parsed and queued.""" - client = CodexClient() - - await client._process_message({ - "jsonrpc": "2.0", - "method": "item/agentMessage/delta", - "params": {"delta": "Hello", "itemId": "1", "threadId": "t", "turnId": "u"}, - }) - - event = await asyncio.wait_for(client._event_queue.get(), timeout=1.0) - assert isinstance(event, AgentMessageDeltaEvent) - assert event.data.delta == "Hello" - - -async def test_send_request_not_connected_raises(): - """Sending request before connecting raises CodexProcessError.""" - client = CodexClient() - with pytest.raises(CodexProcessError, match="Not connected"): - await client._send_request("thread/start") - - -def test_mcp_config_to_toml_stdio(): - """StdioMcpServer serializes to TOML inline format.""" - config = StdioMcpServer(command="npx", args=["-y", "pkg"]) - result = _mcp_config_to_toml_inline("bash", config) - assert result == 'mcp_servers.bash={command = "npx", args = ["-y", "pkg"]}' - - -def test_mcp_config_to_toml_http(): - """HttpMcpServer serializes to TOML inline format.""" - config = HttpMcpServer(url="http://localhost:8000", bearer_token_env_var="TOKEN") - result = _mcp_config_to_toml_inline("api", config) - assert 'url = "http://localhost:8000"' in result - assert 'bearer_token_env_var = "TOKEN"' in result - - -if __name__ == "__main__": - pytest.main([__file__, "-vv"]) diff --git a/tests/test_history.py b/tests/test_history.py index a87710a70..3e12ab974 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -7,8 +7,7 @@ import pytest from agentpool.messaging import ChatMessage, TokenCost -from agentpool.utils.parse_time import parse_time_period -from agentpool.utils.time_utils import get_now +from agentpool.utils.time_utils import get_now, parse_time_period from agentpool_config.storage import SQLStorageConfig from agentpool_storage.models import QueryFilters, StatsFilters from agentpool_storage.sql_provider import SQLModelProvider @@ -52,10 +51,8 @@ async def sample_data(provider: SQLModelProvider): name="user", model_name="gpt-5", session_id="conv1", - cost_info=TokenCost( - token_usage=RunUsage(input_tokens=5, output_tokens=5), - total_cost=Decimal("0.001"), - ), + usage=RunUsage(input_tokens=5, output_tokens=5), + cost_info=TokenCost(total_cost=Decimal("0.001")), ), ChatMessage( content="Hi there!", @@ -63,10 +60,8 @@ async def sample_data(provider: SQLModelProvider): name="test_agent", model_name="gpt-5", session_id="conv1", - cost_info=TokenCost( - token_usage=RunUsage(input_tokens=10, output_tokens=10), - total_cost=Decimal("0.002"), - ), + usage=RunUsage(input_tokens=10, output_tokens=10), + cost_info=TokenCost(total_cost=Decimal("0.002")), ), ChatMessage( content="Testing", @@ -74,10 +69,8 @@ async def sample_data(provider: SQLModelProvider): name="user", model_name="gpt-3.5-turbo", session_id="conv2", - cost_info=TokenCost( - token_usage=RunUsage(input_tokens=7, output_tokens=8), - total_cost=Decimal("0.0015"), - ), + usage=RunUsage(input_tokens=7, output_tokens=8), + cost_info=TokenCost(total_cost=Decimal("0.0015")), ), ] @@ -122,7 +115,7 @@ async def test_get_session_stats(provider: SQLModelProvider, sample_data: None): # Check model grouping assert "gpt-5" in stats assert stats["gpt-5"]["messages"] == 2 - assert stats["gpt-5"]["total_tokens"] == 30 + assert stats["gpt-5"]["usage"].total_tokens == 30 assert "gpt-3.5-turbo" in stats assert stats["gpt-3.5-turbo"]["messages"] == 1 @@ -198,7 +191,7 @@ async def test_filtered_conversations(provider: SQLModelProvider, sample_data: N assert conv["agent"] == "test_agent" assert len(conv["messages"]) == 2 assert conv["token_usage"] is not None - assert conv["token_usage"]["total"] == 30 # 10 + 20 tokens + assert conv["token_usage"].total_tokens == 30 # 10 + 20 tokens async def test_period_filtering(provider: SQLModelProvider, sample_data: None): diff --git a/tests/test_message_tracker.py b/tests/test_message_tracker.py index c565bfbdf..02347edac 100644 --- a/tests/test_message_tracker.py +++ b/tests/test_message_tracker.py @@ -19,7 +19,7 @@ async def test_simple_sequential_chain(): msg = await agent1.run("test") mermaid = tracker.visualize(msg) # Should only see these two connections - connections = mermaid.replace(" ", "").split("\n")[1:] # pyright: ignore + connections = mermaid.replace(" ", "").split("\n")[1:] assert sorted(connections) == sorted(["agent1-->agent2", "agent2-->agent3"]) @@ -38,7 +38,7 @@ async def test_parallel_to_sequential(): async with pool.track_message_flow() as tracker: msg = await agent1.run("test") mermaid = tracker.visualize(msg) - connections = mermaid.replace(" ", "").split("\n")[1:] # pyright: ignore + connections = mermaid.replace(" ", "").split("\n")[1:] assert sorted(connections) == sorted([ "agent1-->agent2", "agent1-->agent3", @@ -62,7 +62,7 @@ def process(msg: str) -> str: async with pool.track_message_flow() as tracker: msg = await agent1.run("test") mermaid = tracker.visualize(msg) - connections = mermaid.replace(" ", "").split("\n")[1:] # pyright: ignore + connections = mermaid.replace(" ", "").split("\n")[1:] assert sorted(connections) == sorted(["agent1-->process", "process-->agent2"]) @@ -156,7 +156,7 @@ async def test_message_flow_tracker_nested(): mermaid = tracker.visualize(result) # Should only show connection to team as a unit - connections = mermaid.replace(" ", "").split("\n")[1:] # pyright: ignore + connections = mermaid.replace(" ", "").split("\n")[1:] assert sorted(connections) == ["agent1-->team"] diff --git a/tests/test_processors.py b/tests/test_processors.py index 287740075..9a7ad5ad6 100644 --- a/tests/test_processors.py +++ b/tests/test_processors.py @@ -4,8 +4,7 @@ if TYPE_CHECKING: - from pydantic_ai import RunContext - from pydantic_ai.messages import ModelMessage + from pydantic_ai import ModelMessage, RunContext def keep_recent(messages: list[ModelMessage]) -> list[ModelMessage]: diff --git a/tests/test_schema_override.py b/tests/test_schema_override.py index dcc812b0d..1c196f233 100644 --- a/tests/test_schema_override.py +++ b/tests/test_schema_override.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any -from pydantic_ai.tools import ToolDefinition +from pydantic_ai import ToolDefinition from agentpool.agents.native_agent.agent import Agent from agentpool.tools.base import Tool diff --git a/tests/tools/test_execution_environment_tools.py b/tests/tools/test_execution_environment_tools.py index 790c87337..f21c4875c 100644 --- a/tests/tools/test_execution_environment_tools.py +++ b/tests/tools/test_execution_environment_tools.py @@ -24,9 +24,9 @@ from agentpool.agents.events import RichAgentStreamEvent -def drain_event_queue(agent: Agent) -> list[RichAgentStreamEvent]: +def drain_event_queue(agent: Agent) -> list[RichAgentStreamEvent[Any]]: """Drain all events from the agent's event queue.""" - events: list[RichAgentStreamEvent] = [] + events: list[RichAgentStreamEvent[Any]] = [] while not agent._event_queue.empty(): try: events.append(agent._event_queue.get_nowait()) @@ -241,7 +241,7 @@ async def failing_start( ) -> str: raise FileNotFoundError("Command not found") - env.process_manager.start_process = failing_start # ty: ignore[invalid-assignment] + env.process_manager.start_process = failing_start # type: ignore[method-assign] # ty:ignore[invalid-assignment] tools = ProcessManagementTools(env=env) result = await tools.start_process(agent_ctx, command="nonexistent") @@ -452,7 +452,7 @@ async def failing_get_info(process_id: str) -> dict[str, Any]: # Start another process, then make info fail await tools.start_process(agent_ctx, command="echo", args=[]) - env.process_manager.get_process_info = failing_get_info # ty: ignore[invalid-assignment] + env.process_manager.get_process_info = failing_get_info # type: ignore[method-assign] # ty:ignore[invalid-assignment] result = await tools.list_processes(agent_ctx) # Tools now return formatted strings diff --git a/tests/tools/test_openapi_toolsets.py b/tests/tools/test_openapi_toolsets.py index 5ea37b2a9..0d1c5c33e 100644 --- a/tests/tools/test_openapi_toolsets.py +++ b/tests/tools/test_openapi_toolsets.py @@ -88,7 +88,7 @@ async def test_openapi_toolset_local(mock_openapi_spec): local_path = mock_openapi_spec["local_path"] toolset = OpenAPITools(spec=local_path, base_url=BASE_URL) spec = await toolset._load_spec() # Load and validate spec - validate(spec) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + validate(spec) # type: ignore[arg-type] tools = await toolset.get_tools() assert len(tools) == 1, f"Expected 1 tool, got {len(tools)}: {tools}" @@ -114,7 +114,7 @@ def mock_client_factory(*args, **kwargs): monkeypatch.setattr("httpx.AsyncClient", mock_client_factory) toolset = OpenAPITools(spec=url, base_url=BASE_URL) spec = await toolset._load_spec() - validate(spec) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + validate(spec) # type: ignore[arg-type] mock_sync_get.assert_called_once() # Verify sync get was called for spec loading tools = await toolset.get_tools() assert len(tools) == 1, f"Expected 1 tool, got {len(tools)}: {tools}" diff --git a/uv.lock b/uv.lock index 6a626ba13..7c4ac8d3e 100644 --- a/uv.lock +++ b/uv.lock @@ -10,42 +10,28 @@ resolution-markers = [ [manifest] constraints = [{ name = "extism-sys", specifier = "<1.13.0" }] -[[package]] -name = "ably" -version = "2.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h2" }, - { name = "httpx" }, - { name = "msgpack" }, - { name = "pyee" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/71/6f44eaff7a0e0ea9a0c134b43c22b80806b7a89f7a16460fa0acacbca6cf/ably-2.1.3.tar.gz", hash = "sha256:e2e0f9e929e82ca55d161b2c4c2abb691ed5ecefc8638c28215c6517ba134297", size = 1044915, upload-time = "2025-12-05T13:40:26.206Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/fb/1c32f05a601a11a4daf8544dc67febf5a228a02e7176da575b9ecfee509b/ably-2.1.3-py3-none-any.whl", hash = "sha256:13760cd1bb60e88630db50d3d232dd83964e81d885ed99f4c66ce267ea4f9992", size = 127991, upload-time = "2025-12-05T13:40:24.624Z" }, -] - [[package]] name = "ag-ui-protocol" -version = "0.1.13" +version = "0.1.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b5/fc0b65b561d00d88811c8a7d98ee735833f81554be244340950e7b65820c/ag_ui_protocol-0.1.13.tar.gz", hash = "sha256:811d7d7dcce4783dec252918f40b717ebfa559399bf6b071c4ba47c0c1e21bcb", size = 5671, upload-time = "2026-02-19T18:40:38.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/71/96c21ae7e2fb9b610c1a90d38bd2de8b6e5b2900a63001f3882f43e519af/ag_ui_protocol-0.1.15.tar.gz", hash = "sha256:5e23c1042c7d4e364d685e68d2fb74d37c16bc83c66d270102d8eaedce56ad82", size = 6269, upload-time = "2026-04-01T15:44:33.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/9f/b833c1ab1999da35ebad54841ae85d2c2764c931da9a6f52d8541b6901b2/ag_ui_protocol-0.1.13-py3-none-any.whl", hash = "sha256:1393fa894c1e8416efe184168a50689e760d05b32f4646eebb8ff423dddf8e8f", size = 8053, upload-time = "2026-02-19T18:40:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a0/a73398d30bb0f9ad70cd70426151a4a19527a7296e48a3a16a50e1d5db05/ag_ui_protocol-0.1.15-py3-none-any.whl", hash = "sha256:85cde077023ccbc37b5ce2ad953537883c262d210320f201fc2ec4e85408b06a", size = 8661, upload-time = "2026-04-01T15:44:32.079Z" }, ] [[package]] name = "agentpool" -version = "2.9.5" +version = "2.9.17" source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "anyenv", extra = ["httpx"] }, + { name = "bashkit" }, { name = "clawd-code-sdk" }, + { name = "codexed" }, { name = "docler" }, { name = "docstring-parser" }, { name = "epregistry" }, @@ -180,6 +166,7 @@ dev = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "syrupy" }, + { name = "ty" }, ] docs = [ { name = "beautifulsoup4" }, @@ -205,8 +192,10 @@ requires-dist = [ { name = "apprise", marker = "extra == 'notifications'", specifier = ">=1.9.5" }, { name = "ast-grep-py", marker = "extra == 'coding'", specifier = ">=0.40.0" }, { name = "autoevals", marker = "extra == 'braintrust'" }, + { name = "bashkit", specifier = ">=0.1.11" }, { name = "braintrust", marker = "extra == 'braintrust'" }, { name = "clawd-code-sdk", specifier = ">=0.1.36" }, + { name = "codexed", specifier = ">=0.0.1" }, { name = "composio", marker = "extra == 'composio'" }, { name = "copykitten", marker = "extra == 'clipboard'" }, { name = "croniter", marker = "extra == 'bot'", specifier = ">=2.0.0" }, @@ -226,7 +215,7 @@ requires-dist = [ { name = "jinja2" }, { name = "jinjarope" }, { name = "keyring", specifier = ">=25.6.0" }, - { name = "lancedb", marker = "python_full_version < '3.14' and extra == 'mcp-discovery'", specifier = ">=0.26.0" }, + { name = "lancedb", marker = "python_full_version < '3.14' and extra == 'mcp-discovery'", specifier = "==0.30.0" }, { name = "langfuse", marker = "extra == 'langfuse'" }, { name = "llmling-models", specifier = ">=1.4.1" }, { name = "logfire", extras = ["fastapi"] }, @@ -287,7 +276,7 @@ dev = [ { name = "check-jsonschema", specifier = ">=0.35.0" }, { name = "devtools" }, { name = "fastembed", marker = "python_full_version < '3.14'", specifier = ">=0.7.4" }, - { name = "lancedb", marker = "python_full_version < '3.14'", specifier = ">=0.26.0" }, + { name = "lancedb", marker = "python_full_version < '3.14'", specifier = "==0.30.0" }, { name = "openapi-spec-validator" }, { name = "pyinstaller", specifier = ">=6.17.0" }, { name = "pyreadline3" }, @@ -298,6 +287,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist" }, { name = "syrupy", specifier = ">=4.0.0" }, + { name = "ty", specifier = ">=0.0.23" }, ] docs = [ { name = "beautifulsoup4", specifier = ">=4.14.3" }, @@ -337,7 +327,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -348,59 +338,59 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -485,7 +475,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.84.0" +version = "0.88.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -497,9 +487,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" }, ] [[package]] @@ -537,14 +527,14 @@ httpx = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] @@ -595,7 +585,7 @@ wheels = [ [[package]] name = "apprise" -version = "1.9.7" +version = "1.9.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -606,9 +596,9 @@ dependencies = [ { name = "requests-oauthlib" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/f5/97dc06b3401bb67abcef6e8bef7155f192b75795c2a2aa4d59eb5aa7fa66/apprise-1.9.7.tar.gz", hash = "sha256:2f73cc1e0264fb119fdb9b7cde82e8fde40a0f531ac885d8c6f0cf0f6e13aec2", size = 1937173, upload-time = "2026-01-20T18:51:32.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822, upload-time = "2026-03-21T17:49:14.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/6b/cfa80a13437896eb8f4504ddac6dfa4ef7f1d2b2261057aa4a30003b8de6/apprise-1.9.7-py3-none-any.whl", hash = "sha256:c7640a81a1097685de66e0508e3da89f49235d566cb44bbead1dd98419bf5ee3", size = 1459879, upload-time = "2026-01-20T18:51:30.766Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940, upload-time = "2026-03-21T17:49:11.847Z" }, ] [[package]] @@ -631,22 +621,22 @@ wheels = [ [[package]] name = "ast-grep-py" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/73/8c78a66d48738feb2d29840bdc26ba8552e239343bb9258931a371329b94/ast_grep_py-0.41.0.tar.gz", hash = "sha256:d02879ecf9cf27f2a51205ff537588377b03eaefecdac1d3d9b7e21292b5c156", size = 138633, upload-time = "2026-02-22T19:10:53.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/80/22bdb1bb1949a09e1f5cec663d6e293115b2c200b5a4f1189a8977c3f457/ast_grep_py-0.42.0.tar.gz", hash = "sha256:822ef35f5cb8bf22b9899ee0fa19e5f7ee4e329e923a89ef5852b4c28a5acad6", size = 147890, upload-time = "2026-03-16T03:38:15.333Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/34/49c879eadb43a065b6e84f6bc2dc351adfb534cf2255e945282a51a1735b/ast_grep_py-0.41.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:db4f5bd240db2d0c834fba77ac1b2c12da22c45c9f72ac34c02bae74e24c6574", size = 4976823, upload-time = "2026-02-22T19:10:28.818Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/7eada105e0047d2e972c37b6673ea1e68da370f5f5b0a98e00d9ebbeb39d/ast_grep_py-0.41.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fa2187f03a27af72fdc6208339d34cdfb367cb5c55a9add4ceccaefbcadb6c7", size = 5120627, upload-time = "2026-02-22T19:10:30.659Z" }, - { url = "https://files.pythonhosted.org/packages/40/64/bd04e03e35533e4b366cd51f5a89c89a13f83a5b56087526318c9a29e1f7/ast_grep_py-0.41.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5b0131debca506d05aef7b6f6a0a2487503aed82527169f10ef72c71cc3d6be2", size = 4944806, upload-time = "2026-02-22T19:10:32.746Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/e4ce62446e8efda72c65df5e2881e58fc11cd3aafdb472e74060329d73a0/ast_grep_py-0.41.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:22737e77d6156cf49c636d06d2e267e3eb7f86998ff9fe085a4ec2814f5b9f5d", size = 5059659, upload-time = "2026-02-22T19:10:35.249Z" }, - { url = "https://files.pythonhosted.org/packages/a2/da/f5060274f2923f94c04d167d14ce4bb9de3aa1d24c47f39295bbd3a886b1/ast_grep_py-0.41.0-cp313-cp313-win32.whl", hash = "sha256:5b12d2667160b8f2f6d973f5bc3f330c294cb726ef8411f2ce8334d481efe3f0", size = 4674201, upload-time = "2026-02-22T19:10:37.175Z" }, - { url = "https://files.pythonhosted.org/packages/59/91/dc6537cb761431b29cee0750afd73452fcf81e7fe8f58929fcc47692ab4c/ast_grep_py-0.41.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8cca57fc3fb8cc44348ba881c52a26b1cdae83547a6d6b30a53550ba7955bc1", size = 4819794, upload-time = "2026-02-22T19:10:39.221Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e8/d4732bc30339f5197c72793ad5c73ddc64bc28a83a8ddcfddad6e2586144/ast_grep_py-0.41.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:595f29761df65d477a7b3b5ff57e88f40a7403c7f6ce0cadc5b467d58033bb55", size = 4974231, upload-time = "2026-02-22T19:10:41.602Z" }, - { url = "https://files.pythonhosted.org/packages/3a/e6/8a5adf31506254adecfdd7313cfb7483b1becf1a57f7feabb4e4ff262a17/ast_grep_py-0.41.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c7d013a347d44a08cc370d60f631aa69c02e60f7b94cd1c9c102d7b3c1f891e6", size = 5118299, upload-time = "2026-02-22T19:10:43.538Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d8/bff4b8645a9071e1d3a1e808854dbd16393c6f7ca304dee6d800bd6515ac/ast_grep_py-0.41.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:76d227202370d873b37fd0bde5d2be117438244fea60a5309f51ec14884b0432", size = 4944333, upload-time = "2026-02-22T19:10:45.344Z" }, - { url = "https://files.pythonhosted.org/packages/25/14/793add5f37794723c58663eb260e82956b44165ed218eb47502d501ca44f/ast_grep_py-0.41.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:bf9c385b81c63f7c4dff0536d3b4e6a30232d1cc496e851445fb5f89c98745c2", size = 5060501, upload-time = "2026-02-22T19:10:47.19Z" }, - { url = "https://files.pythonhosted.org/packages/51/0f/ee93d4105c3fcd08a4a20df6164976d3356e614071ec255bc2846fc1052c/ast_grep_py-0.41.0-cp314-cp314-win32.whl", hash = "sha256:19b3b0ad0ccede1c4153856dbce3832a3801e2cf1be01b37c04e99ba951d3c45", size = 4674807, upload-time = "2026-02-22T19:10:50.556Z" }, - { url = "https://files.pythonhosted.org/packages/94/21/8c51ebed5305d202e6e3d2aba8ee36d6f67fbfd65dd9459c0fa6bf512ce4/ast_grep_py-0.41.0-cp314-cp314-win_amd64.whl", hash = "sha256:5e622fb396045ceabd804f90871d0e66213106a24e826b9a4f7abc9176fc368e", size = 4819754, upload-time = "2026-02-22T19:10:52.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b7/4b896ffdac221cdf96fb87b2749fdfcd3b5c724ed76509318ec8cbc3899b/ast_grep_py-0.42.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:294841afb21724762adea7fd7f8536a3975b1ba70b1e2c1003cc438609ff64ed", size = 5028695, upload-time = "2026-03-16T03:37:55.71Z" }, + { url = "https://files.pythonhosted.org/packages/54/c6/ae674b189d7751133a0fe3bd7a3c5061c411c60af9211f8776f231401592/ast_grep_py-0.42.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a1a3d79bfff8779b1bcdf30de5266a8f4ba1f3cd7d82257cbc26fa45fef3af0", size = 5182383, upload-time = "2026-03-16T03:37:57.136Z" }, + { url = "https://files.pythonhosted.org/packages/39/cb/8a102c1fa2f6a28f142c7b97446f9e15ca51d4c6d964724ab705aa5e8cc4/ast_grep_py-0.42.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ba787ff4637a75d8d57e596317e01374f2a2db12c1f90508ae4d1bbdda96b7bc", size = 5003908, upload-time = "2026-03-16T03:37:59.023Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4e/6f3cdbadc5b32fca0585feb1a9d5075ad175658b833a103491a82152fe9c/ast_grep_py-0.42.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1865477042608ba839573a3c15ca31b1331d105c7f2879384d5105c005c72c7b", size = 5124530, upload-time = "2026-03-16T03:38:00.422Z" }, + { url = "https://files.pythonhosted.org/packages/73/61/ce6b0ff2de0be635953aef84f162d97e89a63b8f7b94f28c9e7ad92a911d/ast_grep_py-0.42.0-cp313-cp313-win32.whl", hash = "sha256:70d499ab3b12dc687e0c72345ff55e597f04399a13cc68449ae76d31779dfac8", size = 4739272, upload-time = "2026-03-16T03:38:01.924Z" }, + { url = "https://files.pythonhosted.org/packages/96/a8/6bcda83dc30aa48e00f0edca149069da0214897a12f7e8d87c9cff54993f/ast_grep_py-0.42.0-cp313-cp313-win_amd64.whl", hash = "sha256:a6c76e9c1b11508a197398d597d2b7ad9b18a044fb685b9d7edd0cee61acb991", size = 4874777, upload-time = "2026-03-16T03:38:03.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cb/97309cd6264602059fe003e76ac186935aa4f5808b8ae21b933594633891/ast_grep_py-0.42.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c479e6a7e856b1b804178cdfcf0fc5ebc3c23aaf1920ec08d675dc5a6539d43a", size = 5028981, upload-time = "2026-03-16T03:38:05.573Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b0/e7adfc731b1894b040cce6c5633ae16d765120a693ffd831a5e5a02e991a/ast_grep_py-0.42.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985676ff57bc292674551a460a7c81020755beae04f4710682fbff62813e38b0", size = 5180507, upload-time = "2026-03-16T03:38:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/15/9a/ece2ff63dafde3f0bc891e52fa606c1336807696b46163f55fb25c9168d9/ast_grep_py-0.42.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:23b2ef30c314cf1b6d5de0c9ad91c9074f52b6204e7a524eedc624f4951c74bc", size = 5003851, upload-time = "2026-03-16T03:38:08.595Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/6bb2e1c3c966a4f5664033e80a2bcaf4d9059f55f281bfc2d433227251ad/ast_grep_py-0.42.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:967415d775b0bef1827ff1e70eb95cb60754d12a5dffea3e4e8daf2bd99ad7fe", size = 5124036, upload-time = "2026-03-16T03:38:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/3adace120b29a55fc88be06458be085bb48859e95923303d449f2d59cbb7/ast_grep_py-0.42.0-cp314-cp314-win32.whl", hash = "sha256:ec2756c8869b30b82b104ef99ecd26949b8e94f28f1dbbc6d1351fb0b5ca43a9", size = 4738138, upload-time = "2026-03-16T03:38:12.373Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/761cf626ddd67a8f75e1b97852847dc0bcfafa484b8126bcdf0c468efff2/ast_grep_py-0.42.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1eacbb33c9b4700a1b001c5b9b9f5edc4d63f85dc4584406652bca7478dcb27", size = 4873757, upload-time = "2026-03-16T03:38:13.797Z" }, ] [[package]] @@ -663,11 +653,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -729,6 +719,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, ] +[[package]] +name = "bashkit" +version = "0.1.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/31/9b38d8f917a1455ee8c100a8e13678d6e41c31688ef2fe24f92228af168b/bashkit-0.1.14.tar.gz", hash = "sha256:145b25c035963c4530b12b9e1d57990d80525ba3f8891a87b111aaeb3b884cb2", size = 905714, upload-time = "2026-03-28T12:55:37.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/25/dcb030c9f95c2cc42802201a82629f9ac9db19498d95850c57fd82d86b85/bashkit-0.1.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e55b764025323e0c6757da15af503e14f7b5743dab805dec65a8426d928a1c90", size = 4408214, upload-time = "2026-03-28T12:55:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/0e/92/3327e44227a3023c603e881a4a590c7b8d2881e956bce0f2cf029fc50c16/bashkit-0.1.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a4701a17183d0c72bad38817728e4fc3204c1220721ea654e019f01412d8b348", size = 4036589, upload-time = "2026-03-28T12:55:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/43/89/bfa3b7308edeb675cff690caf85fc730fedda150e965e791c0ff6d49d7e5/bashkit-0.1.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e129448d12ce6d377a08b4feef94d06aa9f8574867451ab244c0ceca14ecc9", size = 4343645, upload-time = "2026-03-28T12:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3a/8b013b725f577595020a826f1d6e27bb7e900e29b524dd6beb1c946bcf4d/bashkit-0.1.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca388d1c23b6e3361530a914c14a765d11bf4b76971a9c8223c14dc16515a5d0", size = 4601296, upload-time = "2026-03-28T12:56:03.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5c/eb46fc5572c36d64835c64360b518bc3de39ebbe01185f78d0a61b646cb7/bashkit-0.1.14-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:24bdaa2b5c127de5cd4ce04a8212679f7f46535fca543d82469a006935ce4a30", size = 4520577, upload-time = "2026-03-28T12:56:05.219Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/dc5e5fba18ce0a437dd735e90c8e7a9146ffd422c529e9e79402e1ede187/bashkit-0.1.14-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0f2cdac4cc2f18a124b1b31a69c9dc64cb4a081eb3e21b437c87aa9fb48efa79", size = 4845964, upload-time = "2026-03-28T12:56:16.252Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/8e5196f1a7b542ea46a1b9a1ae1074375ee04bdae3f69d3b437b99f4f5dc/bashkit-0.1.14-cp313-cp313-win_amd64.whl", hash = "sha256:879f80e5dd4060cee82c75d7107c59896821b4ba042af97646c45e4ee1331701", size = 4375203, upload-time = "2026-03-28T12:55:52.518Z" }, +] + [[package]] name = "bashlex" version = "0.18" @@ -762,7 +767,7 @@ wheels = [ [[package]] name = "black" -version = "26.1.0" +version = "26.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -772,57 +777,59 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] name = "boto3" -version = "1.42.61" +version = "1.42.81" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/15/356d38280ce3fce37a8e2b44e2ead81240d933f64411e86415a2ed4c0bd5/boto3-1.42.61.tar.gz", hash = "sha256:117ebfc597c95bfb64c6d37ba77bd1c2a97a1885c1dcac2a8be1a14e2139a76d", size = 112750, upload-time = "2026-03-04T20:30:53.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/4d/40029c26b535c41333a0b11573127cfc548fdcb1cbcd1798ea7046c56bab/boto3-1.42.81.tar.gz", hash = "sha256:e5c0d57229763007151be6d388319514a040ccdc922fbb27e37c3100a7fbc01a", size = 112785, upload-time = "2026-04-01T19:35:34.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/d7/a2fa875cb7c5d6b5c5cf6fc181343708c8dc6cafae3e6964ed486ae21bea/boto3-1.42.61-py3-none-any.whl", hash = "sha256:156efcc298a33206be6dfd220815c64aa8b09424017534cabe717636961fc306", size = 140555, upload-time = "2026-03-04T20:30:51.17Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/a1a8e8bbaaa258645fe04bb6a39d7d57b6a12650312f880b8e9add638a56/boto3-1.42.81-py3-none-any.whl", hash = "sha256:216f43e308f1f65e69f57784e5042ffcb2eb6a45e370d118ea384510c148fde7", size = 140554, upload-time = "2026-04-01T19:35:32.71Z" }, ] [[package]] name = "botocore" -version = "1.42.61" +version = "1.42.81" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/6a/27836dde004717c496f69f4fe28fa2f3f3762d04859a9292681944a45a36/botocore-1.42.61.tar.gz", hash = "sha256:702d6011ace2b5b652a0dbb45053d4d9f79da2c5b184463042434e1754bdd601", size = 14954743, upload-time = "2026-03-04T20:30:41.956Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/b0bb9a8768398fb131e1fe722c9cc5b18f74d21ca1970efe8576912b2c6e/botocore-1.42.81.tar.gz", hash = "sha256:48e6f6f52de1cc107a34810309b8ca998ea9bb719a3fe4c06f903a604b3138cb", size = 15129980, upload-time = "2026-04-01T19:35:23.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/46/98a01139f318b7a2f0ad1d1e3be2a028d13aeb7e05aaa340a27cdc47fdf0/botocore-1.42.61-py3-none-any.whl", hash = "sha256:476059beb3f462042742950cf195d26bc313461a77189c16e37e205b0a924b26", size = 14627717, upload-time = "2026-03-04T20:30:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d7/33/c7a01649a6cb7219b233d2ed071ab925e52cdb64e15ce935024c0007376f/botocore-1.42.81-py3-none-any.whl", hash = "sha256:bcef8c93c20ebeba95e4f8b9edfbffbc78a0e11235425a92ee32e48fd8e03c37", size = 14807198, upload-time = "2026-04-01T19:35:20.437Z" }, ] [[package]] name = "braintrust" -version = "0.7.0" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chevron" }, { name = "exceptiongroup" }, { name = "gitpython" }, + { name = "jsonschema" }, + { name = "packaging" }, { name = "python-dotenv" }, { name = "python-slugify" }, { name = "requests" }, @@ -831,9 +838,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/e3/22e894cbc0d42edf53b77b550b95730243273df645b9807b710158088454/braintrust-0.7.0.tar.gz", hash = "sha256:dd5786c5f087dca0c8c5cf0af7806504fddf23e9c5f0f45f7aeaab35c83aa3e8", size = 356980, upload-time = "2026-02-27T18:52:03.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/46/5f3a033a8ee112ec2af0aaf3c209d0ee76542d89db18707f15c2e6b54556/braintrust-0.12.0.tar.gz", hash = "sha256:0bd8d2a82fc28a31153b7097eae79250e184bd1fc21707c94e65058e4f40b750", size = 456527, upload-time = "2026-04-01T16:21:03.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/f7/707bab2fc31bea9f219f55bb0258aca3ba6fefa222655c19d42838d3c97d/braintrust-0.7.0-py3-none-any.whl", hash = "sha256:f7d965f76da64c6f83b9bd296924c4255a689125119a5c9407e7e763133a8d90", size = 412623, upload-time = "2026-02-27T18:52:01.861Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/9eee01398b4f62f09a2062a765f2346c22fc14951d7bd8f1cf20250da6bc/braintrust-0.12.0-py3-none-any.whl", hash = "sha256:f748e79f58da6e94f0806640ebbfe21b0e4c49747f44403c93b851ea47ffabde", size = 529848, upload-time = "2026-04-01T16:21:02.033Z" }, ] [[package]] @@ -880,11 +887,11 @@ wheels = [ [[package]] name = "cachetools" -version = "5.5.2" +version = "7.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, ] [[package]] @@ -904,19 +911,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] -[[package]] -name = "centrifuge-python" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/f7/aa5f0870e6b59cd9d8c7852c9608462903cb69b83bd3f270197aa135bd07/centrifuge_python-0.4.2.tar.gz", hash = "sha256:82743dd0bbdabe12fbdedf665434539981457310c1dd21332e6685533e5a0f46", size = 41578, upload-time = "2025-11-14T14:39:32.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/64/6635b09c8605a66bf7c982eca3141aca0ebf00bb55b17273c84b2eb81b3d/centrifuge_python-0.4.2-py3-none-any.whl", hash = "sha256:97dd58c849c4a231631f2e90f036950a2d6a0c1cd5e757a58e5f659a50a0a4e0", size = 26727, upload-time = "2025-11-14T14:39:31.434Z" }, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -973,48 +967,64 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] [[package]] name = "check-jsonschema" -version = "0.37.0" +version = "0.37.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1023,9 +1033,9 @@ dependencies = [ { name = "requests" }, { name = "ruamel-yaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/9b/384b1a7df9b28b702cb940d96cea0cad77031f408a8859b9641abea5d671/check_jsonschema-0.37.0.tar.gz", hash = "sha256:f1fef56b041e8cd1ad42e340f8422c1f27e00877e29c4f34bce357955b262e9d", size = 399692, upload-time = "2026-02-27T05:18:26.922Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/d4/46468808fcda2bdb824e1f5617095a14cac60f9bcefc954fbfee55712d1b/check_jsonschema-0.37.1.tar.gz", hash = "sha256:00a2ba5cdc95006e0d07e3743f4f23d80b7f30a690706c018c83578610c2e0a0", size = 408161, upload-time = "2026-03-26T02:49:59.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/20/515f96aa04ce0e74c479a90649f18e2eae80d4df34707b0f4ba831b574ea/check_jsonschema-0.37.0-py3-none-any.whl", hash = "sha256:c9a1476746627daf1d3b362d15ea70b4e176588a2de9dbfb6933315553bcb393", size = 383188, upload-time = "2026-02-27T05:18:25.269Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cc/bfa3f5c4b8fdc05956a9426f4d7a9a85c6d6d68c8096722f612bf4db5d08/check_jsonschema-0.37.1-py3-none-any.whl", hash = "sha256:cf672ef4ccd62f9512ac40d28dde135108799ee8d749095ac72b62ecdb796d2e", size = 392983, upload-time = "2026-03-26T02:49:57.808Z" }, ] [[package]] @@ -1039,18 +1049,19 @@ wheels = [ [[package]] name = "clawd-code-sdk" -version = "0.6.2" +version = "1.0.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "anyenv" }, { name = "anyio" }, + { name = "logfire" }, { name = "mcp" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/c1/30664bab6c6452810c1516e2114569341fdf0e6dc5ded81e632edde92d87/clawd_code_sdk-0.6.2.tar.gz", hash = "sha256:e46b59bc3951189ea052487c08f5a9f39db07084ea008f46f0e72702a3a71fad", size = 141209, upload-time = "2026-03-05T02:49:27.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/d0/f3feeaaa3eb2a2620727a408c096da5bec61932ba1eb1b58484f1089774b/clawd_code_sdk-1.0.25.tar.gz", hash = "sha256:1cc4053b4ff5f599ed2067ea175f827b1a0141ae3b78f6d138aa2a8cecdd30e4", size = 162074, upload-time = "2026-04-01T19:34:50.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/98/00bc436a44c9e431d4f95883a63e3869acc6c0bf13aec22c676787bed299/clawd_code_sdk-0.6.2-py3-none-any.whl", hash = "sha256:081e020c295814ff7c56fa2390bbf8df2fc7aa48834bd5d1addbfa50a98c90f7", size = 112609, upload-time = "2026-03-05T02:49:22.761Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/92550852db377f8d30270a8ee5d1ef0ec25b24e75ad9799ed7990c5d99f1/clawd_code_sdk-1.0.25-py3-none-any.whl", hash = "sha256:2c21f3857bead1ef3c1a31ab6e15de86af25262fc387c21c5284bba3bb15ff55", size = 134463, upload-time = "2026-04-01T19:34:53.918Z" }, ] [[package]] @@ -1087,9 +1098,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] +[[package]] +name = "codexed" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyenv" }, + { name = "pydantic" }, + { name = "schemez" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/82/3e92ce7f9959c607ab46af4d821e39412364a261623507ec66d4db79e800/codexed-1.1.3.tar.gz", hash = "sha256:7242533b15da7f5bc66393e18534a351553b1ad8becc95bc1e984037b5d89288", size = 69469, upload-time = "2026-04-01T20:39:45.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/4a/6bd69549b27bfae00a0280679c3822351c7c80e37f29eb84f81c6942984a/codexed-1.1.3-py3-none-any.whl", hash = "sha256:a535c2cd4019697a5ffb90b05be89f1d32a5711a7dc59193b3da17980c6cdb3d", size = 79259, upload-time = "2026-04-01T20:39:46.988Z" }, +] + [[package]] name = "cohere" -version = "5.20.7" +version = "5.21.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastavro" }, @@ -1101,18 +1126,18 @@ dependencies = [ { name = "types-requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/0b/96e2b55a0114ed9d69b3154565f54b764e7530735426290b000f467f4c0f/cohere-5.20.7.tar.gz", hash = "sha256:997ed85fabb3a1e4a4c036fdb520382e7bfa670db48eb59a026803b6f7061dbb", size = 184986, upload-time = "2026-02-25T01:22:18.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/75/4c346f6e2322e545f8452692304bd4eca15a2a0209ab9af6a0d1a7810b67/cohere-5.21.1.tar.gz", hash = "sha256:e5ade4423b928b01ff2038980e1b62b2a5bb412c8ab83e30882753b810a5509f", size = 191272, upload-time = "2026-03-26T15:09:27.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/86/dc991a75e3b9c2007b90dbfaf7f36fdb2457c216f799e26ce0474faf0c1f/cohere-5.20.7-py3-none-any.whl", hash = "sha256:043fef2a12c30c07e9b2c1f0b869fd66ffd911f58d1492f87e901c4190a65914", size = 323389, upload-time = "2026-02-25T01:22:16.902Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5538f02ec6d10fbb84f29c1b18c68ff2a03d7877926a80275efdf8755a9f/cohere-5.21.1-py3-none-any.whl", hash = "sha256:f15592ec60d8cf12f01563db94ec28c388c61269d9617f23c2d6d910e505344e", size = 334262, upload-time = "2026-03-26T15:09:26.284Z" }, ] [[package]] name = "coloraide" -version = "8.6" +version = "8.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5d/fc/b209a63a4f249750410d0a1196d64719839cae2e21703a093821a41f558e/coloraide-8.6.tar.gz", hash = "sha256:35081fc2806a46edd8afab7b24846f1a390a428017c4ad89b2cf1e7cf21c349f", size = 21297534, upload-time = "2026-03-04T04:35:36.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/f6/2d54d752354091b02862c15ddb8451ff7ca6c19e67dc71692a5d80b48e12/coloraide-8.8.1.tar.gz", hash = "sha256:8a59c2639b735d0c0479f82829c88b617a0caa92fd58f9838eac71865c1c93a0", size = 22017311, upload-time = "2026-03-22T20:42:00.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/e2/27949f85de0531e8d732ea32889de3634e453ea0ee25a34daa32e7f52875/coloraide-8.6-py3-none-any.whl", hash = "sha256:2f71c00fb2f8aa8612eb4b4b46ec9ecc9a98b2f07a4948d5f9523ff4adeb4b40", size = 337237, upload-time = "2026-03-04T04:35:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/20/d4/4c00d0027b0cba0e8cc1e03dfdedf1507c5e885dbb96bdb641f170a904dc/coloraide-8.8.1-py3-none-any.whl", hash = "sha256:b7cca1cd4089368d6282f7c4fac2a78c8403af48d89e318a8553939e2728e82d", size = 346912, upload-time = "2026-03-22T20:41:58.347Z" }, ] [[package]] @@ -1126,7 +1151,7 @@ wheels = [ [[package]] name = "composio" -version = "0.11.2" +version = "0.11.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "composio-client" }, @@ -1136,14 +1161,14 @@ dependencies = [ { name = "pysher" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/03/80195bf66271bfe69ec742d4aedcc6adb1cda0fcf0e42feb895de4e1dfdb/composio-0.11.2.tar.gz", hash = "sha256:a175fe0628254fb0b1cb338c1a3be2d1b42f1478999f05e01f4af2660a523e63", size = 149050, upload-time = "2026-03-04T18:39:20.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/77/5e8557041d09b29a960208c560e82c5a79d606396192c9a99b02f79b61dd/composio-0.11.4.tar.gz", hash = "sha256:cb0622fa31926d9ce4f09e4aa7605a7873b4e3e61c7d7d094682025f34a19ebb", size = 170542, upload-time = "2026-03-25T21:47:16.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/02/03c5c74725af28850f8396cd74edbdf3962614f58aeeb9c0ade75f02f332/composio-0.11.2-py3-none-any.whl", hash = "sha256:f7bdf07e22623dd394a16f3f05bc12461bb2e507377e302cc66b267bd00302e3", size = 98272, upload-time = "2026-03-04T18:39:01.825Z" }, + { url = "https://files.pythonhosted.org/packages/41/46/ccc66eb27e753db878dcaea5f001543863f8563b7a0fe754da0964de9cff/composio-0.11.4-py3-none-any.whl", hash = "sha256:7362f3a3ef4c71a37bc5e3983daa12531bbbe36d961ee00c3428a572a15b7d00", size = 116357, upload-time = "2026-03-25T21:47:02.682Z" }, ] [[package]] name = "composio-client" -version = "1.27.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1153,9 +1178,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/2d/5faddf107854a843137ee127946db3a738667948933e0903f1250e5e729a/composio_client-1.27.0.tar.gz", hash = "sha256:684d33a4e701f92d6d2cca1a66b638e509afb46263e9f13266673ee7c55efefe", size = 193444, upload-time = "2026-01-22T13:34:36.914Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/21/f11039315ce859b723f22b5a16bbb9ba53c83568b56621de7136031a5d3c/composio_client-1.29.0.tar.gz", hash = "sha256:5bbc23a47538e9314bb88cdb9144fd270d5a128ce264936a51ab7db51d75801b", size = 220489, upload-time = "2026-03-20T17:39:24.288Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/37/e121ef2986935d374e85ca74315a8bc7fd5208342cabbb6461a49be466a4/composio_client-1.27.0-py3-none-any.whl", hash = "sha256:45697bb0f8a29290271727d9c1a3233e859dc61c515e6669f59408843fa590f0", size = 210495, upload-time = "2026-01-22T13:34:35.311Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/66e103d68a20fc602e144fab77746521c9a98f9206dee8984ce69feea61e/composio_client-1.29.0-py3-none-any.whl", hash = "sha256:9910268e77eead235e2b08fc504f2cfd11ad4987d756e260a8ba95f45cbf1b0a", size = 248522, upload-time = "2026-03-20T17:39:23.154Z" }, ] [[package]] @@ -1173,142 +1198,149 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] name = "croniter" -version = "6.0.0" +version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, - { name = "pytz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + +[[package]] +name = "cronsim" +version = "2.7" +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, ] [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, ] [[package]] name = "cyclopts" -version = "4.7.0" +version = "4.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1316,9 +1348,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/a7/61825c9c46dd9d3d2a231c9792753fc3fe2822a90734a619b1a23ed0f05f/cyclopts-4.7.0.tar.gz", hash = "sha256:1d0fd440b8d21a55d14f830033eb1ac156933424df3e90afeea34cfb3ed73822", size = 163447, upload-time = "2026-03-05T02:57:49.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/c4/2ce2ca1451487dc7d59f09334c3fa1182c46cfcf0a2d5f19f9b26d53ac74/cyclopts-4.10.1.tar.gz", hash = "sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0", size = 166623, upload-time = "2026-03-23T14:43:01.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/08/a631a99df0e9f49c73ec682a9d1e05e5887cf79f04076792aacb4caac6b2/cyclopts-4.7.0-py3-none-any.whl", hash = "sha256:c659d930797a8470f2914a8f8f8be263b339cb6ffb6593b4a59fa9d84b8e0e38", size = 201270, upload-time = "2026-03-05T02:57:50.988Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0b/2261922126b2e50c601fe22d7ff5194e0a4d50e654836260c0665e24d862/cyclopts-4.10.1-py3-none-any.whl", hash = "sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd", size = 204331, upload-time = "2026-03-23T14:43:02.625Z" }, ] [[package]] @@ -1336,7 +1368,7 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.54.1" +version = "0.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -1345,13 +1377,12 @@ dependencies = [ { name = "inflect" }, { name = "isort" }, { name = "jinja2" }, - { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/4b/6a63ea00c65402576e05e8cc963349ffe58db07d8c8183ab51488dbfb67a/datamodel_code_generator-0.54.1.tar.gz", hash = "sha256:dd9eb7594f94a8b85d7e410f4d997a443cf7a52a1dcc049fae6cf35660f18803", size = 829716, upload-time = "2026-03-04T04:15:02.582Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/36/ec505ce62c143c0f045e82e2bb0360e2ede765c0cfe3a70bf32c5661b8a2/datamodel_code_generator-0.55.0.tar.gz", hash = "sha256:20ae7a4fbbb12be380f0bd02544db4abae96c5b644d4b3f2b9c3fc0bc9ee1184", size = 833828, upload-time = "2026-03-10T20:41:15.796Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/ce/8a8aadbb2fb428109949d0f7a42232d1d452dab0b8550f6e8c5843afa93d/datamodel_code_generator-0.54.1-py3-none-any.whl", hash = "sha256:67c59ff2368eb2ec96ba11441bc8957bb68a71459dd37275a78ea90238ad5f01", size = 264344, upload-time = "2026-03-04T04:15:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/be/c6/2abc9d11adbbf689b6b4dfb7a136d57b9ccaa3b3f1ba83504462109e8dbb/datamodel_code_generator-0.55.0-py3-none-any.whl", hash = "sha256:efa5a925288ca2a135fdc3361c7d774ae5b24b4fd632868363e249d55ea2f137", size = 256860, upload-time = "2026-03-10T20:41:13.488Z" }, ] [package.optional-dependencies] @@ -1382,7 +1413,7 @@ name = "deprecation" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } wheels = [ @@ -1469,7 +1500,7 @@ wheels = [ [[package]] name = "edge-tts" -version = "7.2.7" +version = "7.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1477,9 +1508,9 @@ dependencies = [ { name = "tabulate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/d2/1ce38f6e4fe7275207f4033b0971db489a0b594340ae6bac2320127e71ee/edge_tts-7.2.7.tar.gz", hash = "sha256:0127fba57a742bc48ff0a2a3b24b8324f7859260185274c335b4e54735aff325", size = 27508, upload-time = "2025-12-12T20:54:28.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/60/afbf548b43c78355e03926c6b1fff7500303a2da4d84db9e1324119e21ae/edge_tts-7.2.8.tar.gz", hash = "sha256:fcf185a0d527a0d2d003f9d5841facc1d5e0e7b3b88d5df9c32990402c6b8cd0", size = 27875, upload-time = "2026-03-22T19:57:50.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/89/92ac6b154ab87d236c15e5e0c73cb99be58efb1ea3eb9318c266bf9a36bf/edge_tts-7.2.7-py3-none-any.whl", hash = "sha256:ac11d9e834347e5ee62cbe72e8a56ffd65d3c4e795be14b1e593b72cf6480dd9", size = 30556, upload-time = "2025-12-12T20:54:26.956Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2b/a8cb687b92a2690d2ad171f0c2fd1c8f18690363cca7618bab2bbe4cdf2b/edge_tts-7.2.8-py3-none-any.whl", hash = "sha256:361fe48ce7ef613adbe30f664e3765dd71029c6cb57427279eff8ad6df2eb211", size = 31026, upload-time = "2026-03-22T19:57:49.672Z" }, ] [[package]] @@ -1537,7 +1568,7 @@ all = [ [[package]] name = "exa-py" -version = "2.7.0" +version = "2.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpcore" }, @@ -1548,9 +1579,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/0c/3ffaf0379867c9812c44646fc2d3410fce3692ceab9d70730c24c8116b5c/exa_py-2.7.0.tar.gz", hash = "sha256:d2df74c83d9ee45eaa3677a53aace7335df9f0778720c571033a7edcfcb016d5", size = 49580, upload-time = "2026-03-04T01:01:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/4f/f06a6f277d668f143e330fe503b0027cc5fed753b22c3e161f8cbbccdf65/exa_py-2.10.2.tar.gz", hash = "sha256:f781f30b199f1102333384728adae64bb15a6bbcabfa97e91fd705f90acffc45", size = 53792, upload-time = "2026-03-26T20:29:35.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/e2/663215c8b39df8b867b3a063fe333289873b4946c2136d6a1679a438aa51/exa_py-2.7.0-py3-none-any.whl", hash = "sha256:5780a34b4bcf8738ddc3226c0427f02e2f68753c95d932f0d5f5f5604447e92a", size = 64486, upload-time = "2026-03-04T01:01:40.911Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/7a34e904a415040ba626948d0b0a36a08cd073f12b13342578a68331be3c/exa_py-2.10.2-py3-none-any.whl", hash = "sha256:ecb2a7581f4b7a8aeb6b434acce1bbc40f92ed1d4126b2aa6029913acd904a47", size = 72248, upload-time = "2026-03-26T20:29:37.306Z" }, ] [[package]] @@ -1582,14 +1613,14 @@ wheels = [ [[package]] name = "extism" -version = "1.0.4" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "extism-sys" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/63/992500153f7f3d5f22aa0ee77037846fc085c8cdeb8e9e0513ce2f1c9811/extism-1.0.4.tar.gz", hash = "sha256:cfd9ed5200a9de8ab77d404c43ee2cae715132d00a06e26a3037e83b1458c86f", size = 11431, upload-time = "2025-01-29T19:53:38.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c6/573488066680ef80ba6a46ab5e0df42aa4beafac51d2c3cc140a062531af/extism-1.1.1.tar.gz", hash = "sha256:067bf4ebd89ba85681508a77eb7f213ab13d2dbd6e71dabdac5d9622c61ee1d1", size = 11589, upload-time = "2026-03-26T22:03:42.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/be/2a89afe2501d002cdfb748ac2151414b8f782be2b1ebc9582e1d4fb38fd4/extism-1.0.4-py3-none-any.whl", hash = "sha256:db4ac909c795a7ea03ca129e00ce9e8f2cfa9e68ae0f6772fb0848958392a176", size = 11026, upload-time = "2025-01-29T19:53:37.155Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/f730be96df64fd3c85b0c24f16993181f72b72dfba3099ad86d49a55e7c0/extism-1.1.1-py3-none-any.whl", hash = "sha256:157692d7dc79cde6b1c9759eaf765f04b34b28e1f3a4a84507294c06081c8e5b", size = 11210, upload-time = "2026-03-26T22:03:42.092Z" }, ] [[package]] @@ -1659,7 +1690,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.135.1" +version = "0.135.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1668,9 +1699,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, ] [[package]] @@ -1704,28 +1735,28 @@ wheels = [ [[package]] name = "fastembed" -version = "0.7.4" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub" }, - { name = "loguru" }, - { name = "mmh3" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "pillow" }, - { name = "py-rust-stemmers" }, - { name = "requests" }, - { name = "tokenizers" }, - { name = "tqdm" }, + { name = "huggingface-hub", marker = "python_full_version < '3.14'" }, + { name = "loguru", marker = "python_full_version < '3.14'" }, + { name = "mmh3", marker = "python_full_version < '3.14'" }, + { name = "numpy", marker = "python_full_version < '3.14'" }, + { name = "onnxruntime", marker = "python_full_version < '3.14'" }, + { name = "pillow", marker = "python_full_version < '3.14'" }, + { name = "py-rust-stemmers", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, + { name = "tokenizers", marker = "python_full_version < '3.14'" }, + { name = "tqdm", marker = "python_full_version < '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c2/9c708680de1b54480161e0505f9d6d3d8eb47a1dc1a1f7f3c5106ba355d2/fastembed-0.7.4.tar.gz", hash = "sha256:8b8a4ea860ca295002f4754e8f5820a636e1065a9444959e18d5988d7f27093b", size = 68807, upload-time = "2025-12-05T12:08:10.447Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/3b/8da01492bc8b69184257d0c951bf0e77aec8ce110f06d8ce16c6ed9084f7/fastembed-0.7.4-py3-none-any.whl", hash = "sha256:79250a775f70bd6addb0e054204df042b5029ecae501e40e5bbd08e75844ad83", size = 108491, upload-time = "2025-12-05T12:08:09.059Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, ] [[package]] name = "fastmcp" -version = "3.1.0" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib" }, @@ -1750,9 +1781,9 @@ dependencies = [ { name = "watchfiles" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/70/862026c4589441f86ad3108f05bfb2f781c6b322ad60a982f40b303b47d7/fastmcp-3.1.0.tar.gz", hash = "sha256:e25264794c734b9977502a51466961eeecff92a0c2f3b49c40c070993628d6d0", size = 17347083, upload-time = "2026-03-03T02:43:11.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/32/4f1b2cfd7b50db89114949f90158b1dcc2c92a1917b9f57c0ff24e47a2f4/fastmcp-3.2.0.tar.gz", hash = "sha256:d4830b8ffc3592d3d9c76dc0f398904cf41f04910e41a0de38cc1004e0903bef", size = 26318581, upload-time = "2026-03-30T20:25:37.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/07/516f5b20d88932e5a466c2216b628e5358a71b3a9f522215607c3281de05/fastmcp-3.1.0-py3-none-any.whl", hash = "sha256:b1f73b56fd3b0cb2bd9e2a144fc650d5cc31587ed129d996db7710e464ae8010", size = 633749, upload-time = "2026-03-03T02:43:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/4f/67/684fa2d2de1e7504549d4ca457b4f854ccec3cd3be03bd86b33b599fbf58/fastmcp-3.2.0-py3-none-any.whl", hash = "sha256:e71aba3df16f86f546a4a9e513261d3233bcc92bef0dfa647bac3fa33623f681", size = 705550, upload-time = "2026-03-30T20:25:35.499Z" }, ] [[package]] @@ -1769,11 +1800,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.25.0" +version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] @@ -1859,24 +1890,24 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.2.0" +version = "2026.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] [[package]] name = "genai-prices" -version = "0.0.55" +version = "0.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/67/de9d9be180db6d80b298c281dff71502095c0776d7cc9286f486f667f61a/genai_prices-0.0.55.tar.gz", hash = "sha256:8692c65d0deefe2ad0680d71841eb12822a35945a6060d2b6adbcbdf4945e1cb", size = 59987, upload-time = "2026-02-26T17:56:41.467Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/6b/94b3018a672c7775edfb485f0fed8f6068fba75e49b067e8a1ac5eb96764/genai_prices-0.0.56.tar.gz", hash = "sha256:ac24b16a84d0ab97539bfa48dfa4649689de8e3ce71c12ebacef29efb1998045", size = 65872, upload-time = "2026-03-20T20:33:00.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/98/66a06b82a5c840f896490d5ef9c7691776b147589f2e8d2fa66c67a3db9c/genai_prices-0.0.55-py3-none-any.whl", hash = "sha256:ccd795c90c926b3c71066bf5656f14c67fc11fdba6d71e072c7fb4fa311e1b12", size = 62603, upload-time = "2026-02-26T17:56:40.502Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f6/8ef7e4c286deb2709d11ca96a5237caae3ef4876ab3c48095856cfd2df30/genai_prices-0.0.56-py3-none-any.whl", hash = "sha256:dbe86be8f3f556bed1b72209ed36851fec8b01793b3b220f42921a4e7da945f6", size = 68966, upload-time = "2026-03-20T20:33:02.555Z" }, ] [[package]] @@ -1954,16 +1985,15 @@ wheels = [ [[package]] name = "google-auth" -version = "2.48.0" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, - { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] [package.optional-dependencies] @@ -1973,7 +2003,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.66.0" +version = "1.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1987,21 +2017,21 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/0b343b0770d4710ad2979fd9301d7caa56c940174d5361ed4a7cc4979241/google_genai-1.66.0.tar.gz", hash = "sha256:ffc01647b65046bca6387320057aa51db0ad64bcc72c8e3e914062acfa5f7c49", size = 504386, upload-time = "2026-03-04T22:15:28.156Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/dd/28e4682904b183acbfad3fe6409f13a42f69bb8eab6e882d3bcbea1dde01/google_genai-1.70.0.tar.gz", hash = "sha256:36b67b0fc6f319e08d1f1efd808b790107b1809c8743a05d55dfcf9d9fad7719", size = 519550, upload-time = "2026-04-01T10:52:46.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/dd/403949d922d4e261b08b64aaa132af4e456c3b15c8e2a2d9e6ef693f66e2/google_genai-1.66.0-py3-none-any.whl", hash = "sha256:7f127a39cf695277104ce4091bb26e417c59bb46e952ff3699c3a982d9c474ee", size = 732174, upload-time = "2026-03-04T22:15:26.63Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d4564c8a9beaf6a3cef8d70fa6354318572cebfee65db4f01af0d41f45ba/google_genai-1.70.0-py3-none-any.whl", hash = "sha256:b74c24549d8b4208f4c736fd11857374788e1ffffc725de45d706e35c97fceee", size = 760584, upload-time = "2026-04-01T10:52:44.349Z" }, ] [[package]] name = "googleapis-common-protos" -version = "1.72.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] [[package]] @@ -2051,41 +2081,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/79/29f1373b2ce1eec37c03aefbc17194c2470d8b61ede288e5043231825999/grep_ast-0.9.0-py3-none-any.whl", hash = "sha256:a3973dca99f1abc026a01bbbc70e00a63860c8ff94a56182ff18b089836826d7", size = 13918, upload-time = "2025-05-08T01:08:27.481Z" }, ] -[[package]] -name = "griffe" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffecli" }, - { name = "griffelib" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/94/ee21d41e7eb4f823b94603b9d40f86d3c7fde80eacc2c3c71845476dddaa/griffe-2.0.0-py3-none-any.whl", hash = "sha256:5418081135a391c3e6e757a7f3f156f1a1a746cc7b4023868ff7d5e2f9a980aa", size = 5214, upload-time = "2026-02-09T19:09:44.105Z" }, -] - -[[package]] -name = "griffecli" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "griffelib" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ed/d93f7a447bbf7a935d8868e9617cbe1cadf9ee9ee6bd275d3040fbf93d60/griffecli-2.0.0-py3-none-any.whl", hash = "sha256:9f7cd9ee9b21d55e91689358978d2385ae65c22f307a63fb3269acf3f21e643d", size = 9345, upload-time = "2026-02-09T19:09:42.554Z" }, -] - [[package]] name = "griffelib" -version = "2.0.0" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] name = "groq" -version = "1.0.0" +version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2095,40 +2102,40 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/c7/a2153b639062f59f9bc93a1b5507c0c4a6b654b8a9edbf432ec2f4a62d2d/groq-1.1.2.tar.gz", hash = "sha256:9ec2b5b6a1c4856a8c6c38741353c5ab37472a4e3fded02af783750d849cc988", size = 154033, upload-time = "2026-03-25T23:16:10.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" }, + { url = "https://files.pythonhosted.org/packages/34/b0/83e3892a4597a4b8ebf8a662aeaf314765c4c2340516eb1d049b459b24fc/groq-1.1.2-py3-none-any.whl", hash = "sha256:348cb7a674b6aa7105719b533f6fc48fd32b503bc9256924aaed6dc186f778b5", size = 141700, upload-time = "2026-03-25T23:16:08.998Z" }, ] [[package]] name = "grpcio" -version = "1.78.0" +version = "1.80.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, - { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, - { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, - { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, - { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, - { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] [[package]] @@ -2140,49 +2147,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, -] - [[package]] name = "hf-xet" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/cb/9bb543bd987ffa1ee48202cc96a756951b734b79a542335c566148ade36c/hf_xet-1.3.2.tar.gz", hash = "sha256:e130ee08984783d12717444e538587fa2119385e5bd8fc2bb9f930419b73a7af", size = 643646, upload-time = "2026-02-27T17:26:08.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/75/462285971954269432aad2e7938c5c7ff9ec7d60129cec542ab37121e3d6/hf_xet-1.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:335a8f36c55fd35a92d0062f4e9201b4015057e62747b7e7001ffb203c0ee1d2", size = 3761019, upload-time = "2026-02-27T17:25:49.441Z" }, - { url = "https://files.pythonhosted.org/packages/35/56/987b0537ddaf88e17192ea09afa8eca853e55f39a4721578be436f8409df/hf_xet-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c1ae4d3a716afc774e66922f3cac8206bfa707db13f6a7e62dfff74bfc95c9a8", size = 3521565, upload-time = "2026-02-27T17:25:47.469Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5c/7e4a33a3d689f77761156cc34558047569e54af92e4d15a8f493229f6767/hf_xet-1.3.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6dbdf231efac0b9b39adcf12a07f0c030498f9212a18e8c50224d0e84ab803d", size = 4176494, upload-time = "2026-02-27T17:25:40.247Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b3/71e856bf9d9a69b3931837e8bf22e095775f268c8edcd4a9e8c355f92484/hf_xet-1.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c1980abfb68ecf6c1c7983379ed7b1e2b49a1aaf1a5aca9acc7d48e5e2e0a961", size = 3955601, upload-time = "2026-02-27T17:25:38.376Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/aecf97b3f0a981600a67ff4db15e2d433389d698a284bb0ea5d8fcdd6f7f/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1c88fbd90ad0d27c46b77a445f0a436ebaa94e14965c581123b68b1c52f5fd30", size = 4154770, upload-time = "2026-02-27T17:25:56.756Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e1/3af961f71a40e09bf5ee909842127b6b00f5ab4ee3817599dc0771b79893/hf_xet-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:35b855024ca37f2dd113ac1c08993e997fbe167b9d61f9ef66d3d4f84015e508", size = 4394161, upload-time = "2026-02-27T17:25:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/a1/c3/859509bade9178e21b8b1db867b8e10e9f817ab9ac1de77cb9f461ced765/hf_xet-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:31612ba0629046e425ba50375685a2586e11fb9144270ebabd75878c3eaf6378", size = 3637377, upload-time = "2026-02-27T17:26:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/05/7f/724cfbef4da92d577b71f68bf832961c8919f36c60d28d289a9fc9d024d4/hf_xet-1.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:433c77c9f4e132b562f37d66c9b22c05b5479f243a1f06a120c1c06ce8b1502a", size = 3497875, upload-time = "2026-02-27T17:26:09.034Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/9d54c1ae1d05fb704f977eca1671747babf1957f19f38ae75c5933bc2dc1/hf_xet-1.3.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c34e2c7aefad15792d57067c1c89b2b02c1bbaeabd7f8456ae3d07b4bbaf4094", size = 3761076, upload-time = "2026-02-27T17:25:55.42Z" }, - { url = "https://files.pythonhosted.org/packages/f2/8a/08a24b6c6f52b5d26848c16e4b6d790bb810d1bf62c3505bed179f7032d3/hf_xet-1.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4bc995d6c41992831f762096020dc14a65fdf3963f86ffed580b596d04de32e3", size = 3521745, upload-time = "2026-02-27T17:25:54.217Z" }, - { url = "https://files.pythonhosted.org/packages/b5/db/a75cf400dd8a1a8acf226a12955ff6ee999f272dfc0505bafd8079a61267/hf_xet-1.3.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:959083c89dee30f7d6f890b36cdadda823386c4de63b1a30384a75bfd2ae995d", size = 4176301, upload-time = "2026-02-27T17:25:46.044Z" }, - { url = "https://files.pythonhosted.org/packages/01/40/6c4c798ffdd83e740dd3925c4e47793b07442a9efa3bc3866ba141a82365/hf_xet-1.3.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cfa760888633b08c01b398d212ce7e8c0d7adac6c86e4b20dfb2397d8acd78ee", size = 3955437, upload-time = "2026-02-27T17:25:44.703Z" }, - { url = "https://files.pythonhosted.org/packages/0c/09/9a3aa7c5f07d3e5cc57bb750d12a124ffa72c273a87164bd848f9ac5cc14/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3155a02e083aa21fd733a7485c7c36025e49d5975c8d6bda0453d224dd0b0ac4", size = 4154535, upload-time = "2026-02-27T17:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e0/831f7fa6d90cb47a230bc23284b502c700e1483bbe459437b3844cdc0776/hf_xet-1.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91b1dc03c31cbf733d35dc03df7c5353686233d86af045e716f1e0ea4a2673cf", size = 4393891, upload-time = "2026-02-27T17:26:06.607Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/6ed472fdce7f8b70f5da6e3f05be76816a610063003bfd6d9cea0bbb58a3/hf_xet-1.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:211f30098512d95e85ad03ae63bd7dd2c4df476558a5095d09f9e38e78cbf674", size = 3637583, upload-time = "2026-02-27T17:26:17.349Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/a069edc4570b3f8e123c0b80fadc94530f3d7b01394e1fc1bb223339366c/hf_xet-1.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4a6817c41de7c48ed9270da0b02849347e089c5ece9a0e72ae4f4b3a57617f82", size = 3497977, upload-time = "2026-02-27T17:26:14.966Z" }, - { url = "https://files.pythonhosted.org/packages/d8/28/dbb024e2e3907f6f3052847ca7d1a2f7a3972fafcd53ff79018977fcb3e4/hf_xet-1.3.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f93b7595f1d8fefddfede775c18b5c9256757824f7f6832930b49858483cd56f", size = 3763961, upload-time = "2026-02-27T17:25:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/e4/71/b99aed3823c9d1795e4865cf437d651097356a3f38c7d5877e4ac544b8e4/hf_xet-1.3.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:a85d3d43743174393afe27835bde0cd146e652b5fcfdbcd624602daef2ef3259", size = 3526171, upload-time = "2026-02-27T17:25:50.968Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ca/907890ce6ef5598b5920514f255ed0a65f558f820515b18db75a51b2f878/hf_xet-1.3.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c2a054a97c44e136b1f7f5a78f12b3efffdf2eed3abc6746fc5ea4b39511633", size = 4180750, upload-time = "2026-02-27T17:25:43.125Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ad/bc7f41f87173d51d0bce497b171c4ee0cbde1eed2d7b4216db5d0ada9f50/hf_xet-1.3.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:06b724a361f670ae557836e57801b82c75b534812e351a87a2c739f77d1e0635", size = 3961035, upload-time = "2026-02-27T17:25:41.837Z" }, - { url = "https://files.pythonhosted.org/packages/73/38/600f4dda40c4a33133404d9fe644f1d35ff2d9babb4d0435c646c63dd107/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:305f5489d7241a47e0458ef49334be02411d1d0f480846363c1c8084ed9916f7", size = 4161378, upload-time = "2026-02-27T17:26:00.365Z" }, - { url = "https://files.pythonhosted.org/packages/00/b3/7bc1ff91d1ac18420b7ad1e169b618b27c00001b96310a89f8a9294fe509/hf_xet-1.3.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06cdbde243c85f39a63b28e9034321399c507bcd5e7befdd17ed2ccc06dfe14e", size = 4398020, upload-time = "2026-02-27T17:26:03.977Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0b/99bfd948a3ed3620ab709276df3ad3710dcea61976918cce8706502927af/hf_xet-1.3.2-cp37-abi3-win_amd64.whl", hash = "sha256:9298b47cce6037b7045ae41482e703c471ce36b52e73e49f71226d2e8e5685a1", size = 3641624, upload-time = "2026-02-27T17:26:13.542Z" }, - { url = "https://files.pythonhosted.org/packages/cc/02/9a6e4ca1f3f73a164c0cd48e41b3cc56585dcc37e809250de443d673266f/hf_xet-1.3.2-cp37-abi3-win_arm64.whl", hash = "sha256:83d8ec273136171431833a6957e8f3af496bee227a0fe47c7b8b39c106d1749a", size = 3503976, upload-time = "2026-02-27T17:26:12.123Z" }, +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, + { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] [[package]] @@ -2204,15 +2198,6 @@ async = [ { name = "anysqlite" }, ] -[[package]] -name = "hpack" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -2279,7 +2264,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.5.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -2292,18 +2277,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/76/b5efb3033d8499b17f9386beaf60f64c461798e1ee16d10bc9c0077beba5/huggingface_hub-1.5.0.tar.gz", hash = "sha256:f281838db29265880fb543de7a23b0f81d3504675de82044307ea3c6c62f799d", size = 695872, upload-time = "2026-02-26T15:35:32.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/2a/a847fd02261cd051da218baf99f90ee7c7040c109a01833db4f838f25256/huggingface_hub-1.8.0.tar.gz", hash = "sha256:c5627b2fd521e00caf8eff4ac965ba988ea75167fad7ee72e17f9b7183ec63f3", size = 735839, upload-time = "2026-03-25T16:01:28.152Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/74/2bc951622e2dbba1af9a460d93c51d15e458becd486e62c29cc0ccb08178/huggingface_hub-1.5.0-py3-none-any.whl", hash = "sha256:c9c0b3ab95a777fc91666111f3b3ede71c0cdced3614c553a64e98920585c4ee", size = 596261, upload-time = "2026-02-26T15:35:31.1Z" }, -] - -[[package]] -name = "hyperframe" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/8a3a16ea4d202cb641b51d2681bdd3d482c1c592d7570b3fa264730829ce/huggingface_hub-1.8.0-py3-none-any.whl", hash = "sha256:d3eb5047bd4e33c987429de6020d4810d38a5bef95b3b40df9b17346b7f353f2", size = 625208, upload-time = "2026-03-25T16:01:26.603Z" }, ] [[package]] @@ -2349,15 +2325,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "invoke" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, -] - [[package]] name = "isort" version = "8.0.1" @@ -2381,11 +2348,11 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.1.0" +version = "6.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] [[package]] @@ -2524,14 +2491,23 @@ wheels = [ [[package]] name = "json-schema-to-pydantic" -version = "0.4.10" +version = "0.4.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/05/ce0ada3b13f5fee49520d800d8899fa950c7a5246c24997c10bee49f5791/json_schema_to_pydantic-0.4.10.tar.gz", hash = "sha256:d119b8ff90ccca7899da37e67d689551086614ccd0b79e7c8c9ea2ea7c780fe4", size = 55249, upload-time = "2026-03-03T21:05:52.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/d8/423895b918706c80db1cee679c13fbe810200b9a9d9a9442c7a58d35c3f2/json_schema_to_pydantic-0.4.11.tar.gz", hash = "sha256:35448ed711a28dd33396b095c8492939b4925aa30eb31942e9b8e08d04279465", size = 56597, upload-time = "2026-03-09T20:53:55.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/64/7cfeb8c6d2a5e73e0f8d732032aa62be9a7724c04beb461d677de0b4beb3/json_schema_to_pydantic-0.4.11-py3-none-any.whl", hash = "sha256:da2ccc39d070ee03dbcf0517d16720e3e33f7aa8d61257ace09af8c51bd46348", size = 17842, upload-time = "2026-03-09T20:53:54.576Z" }, +] + +[[package]] +name = "jsonpath-python" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/1a/97568a08142b09bb79615c79db3fb484171430c19d11ddf019be248407b6/json_schema_to_pydantic-0.4.10-py3-none-any.whl", hash = "sha256:388a843b2f0b90a3ac3efd8cbc73bc604d74d32e6ee2f62fce3d3b3e8ba01af9", size = 17146, upload-time = "2026-03-03T21:05:51.103Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" }, ] [[package]] @@ -2603,72 +2579,70 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.5.2" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "lance-namespace-urllib3-client" }, + { name = "lance-namespace-urllib3-client", marker = "python_full_version < '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/c6/aec0d7752e15536564b50cf9a8926f0e5d7780aa3ab8ce8bca46daa55659/lance_namespace-0.5.2.tar.gz", hash = "sha256:566cc33091b5631793ab411f095d46c66391db0a62343cd6b4470265bb04d577", size = 10274, upload-time = "2026-02-20T03:14:31.777Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/9f/7906ba4117df8d965510285eaf07264a77de2fd283b9d44ec7fc63a4a57a/lance_namespace-0.6.1.tar.gz", hash = "sha256:f0deea442bd3f1056a8e2fed056ae2778e3356517ec2e680db049058b824d131", size = 10666, upload-time = "2026-03-17T17:55:44.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/3d/737c008d8fb2861e7ce260e2ffab0d5058eae41556181f80f1a1c3b52ef5/lance_namespace-0.5.2-py3-none-any.whl", hash = "sha256:6ccaf5649bf6ee6aa92eed9c535a114b7b4eb08e89f40426f58bc1466cbcffa3", size = 12087, upload-time = "2026-02-20T03:14:35.261Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/aee1c0a04d17f2810173bd304bd444eb78332045df1b0c1b07cebd01f530/lance_namespace-0.6.1-py3-none-any.whl", hash = "sha256:9699c9e3f12236e5e08ea979cc4e036a8e3c67ed2f37ae6f25c5353ab908e1be", size = 12498, upload-time = "2026-03-17T17:55:44.062Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.5.2" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, - { name = "urllib3" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "python-dateutil", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "urllib3", marker = "python_full_version < '3.14'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/64/51622c93ec8c164483c83b68764e5e76e52286c0137a8247bc6a7fac25f4/lance_namespace_urllib3_client-0.5.2.tar.gz", hash = "sha256:8a3a238006e6eabc01fc9d385ac3de22ba933aef0ae8987558f3c3199c9b3799", size = 172578, upload-time = "2026-02-20T03:14:33.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/a1/8706a2be25bd184acccc411e48f1a42a4cbf3b6556cba15b9fcf4c15cfcc/lance_namespace_urllib3_client-0.6.1.tar.gz", hash = "sha256:31fbd058ce1ea0bf49045cdeaa756360ece0bc61e9e10276f41af6d217debe87", size = 182567, upload-time = "2026-03-17T17:55:46.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/10/f86d994498b37f7f35d0b8c2f7626a16fe4cb1949b518c1e5d5052ecf95f/lance_namespace_urllib3_client-0.5.2-py3-none-any.whl", hash = "sha256:83cefb6fd6e5df0b99b5e866ee3d46300d375b75e8af32c27bc16fbf7c1a5978", size = 300351, upload-time = "2026-02-20T03:14:34.236Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/cb9580602dec25f0fdd6005c1c9ba1d4c8c0c3dc8d543107e5a9f248bba8/lance_namespace_urllib3_client-0.6.1-py3-none-any.whl", hash = "sha256:b9c103e1377ad46d2bd70eec894bfec0b1e2133dae0964d7e4de543c6e16293b", size = 317111, upload-time = "2026-03-17T17:55:45.546Z" }, ] [[package]] name = "lancedb" -version = "0.29.2" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecation" }, - { name = "lance-namespace" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "tqdm" }, + { name = "deprecation", marker = "python_full_version < '3.14'" }, + { name = "lance-namespace", marker = "python_full_version < '3.14'" }, + { name = "numpy", marker = "python_full_version < '3.14'" }, + { name = "packaging", marker = "python_full_version < '3.14'" }, + { name = "pyarrow", marker = "python_full_version < '3.14'" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "tqdm", marker = "python_full_version < '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/77/fbb25946a234928958e016c5448343fd314bd601315f9587568321591a17/lancedb-0.29.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc1faf2e12addb9585569d0fb114ecc25ec3867e4e1aa6934e9343cfb5265ee4", size = 42341708, upload-time = "2026-02-09T06:21:31.677Z" }, - { url = "https://files.pythonhosted.org/packages/cd/95/d3a7b6d0237e343ad5b2afef2bdb99423746d5c3e882a9cab68dc041c2d0/lancedb-0.29.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fec19cfc52a5b9d98e060bd2f02a1c9df6a0bfd15b36021b6017327a41893a3", size = 44147347, upload-time = "2026-02-09T06:31:02.567Z" }, - { url = "https://files.pythonhosted.org/packages/66/21/153a42294279c5b66d763f357808dde0899b71c5c8e41ad5ecbeeb8728df/lancedb-0.29.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:636939ab9225d435020ba17c231f5eaba15312a07813bcebcd71128204cc039f", size = 47186355, upload-time = "2026-02-09T06:34:47.726Z" }, - { url = "https://files.pythonhosted.org/packages/a2/f7/f7041ae7d7730332b2754fe7adc2e0bd496f92bf526ac710b7eb3caf1d0a/lancedb-0.29.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f79b32083fcab139009db521d2f7fcd6afe4cca98a78c06c5940ff00a170cc1a", size = 44172354, upload-time = "2026-02-09T06:31:03.834Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/c152497c18cea0f36b523fc03b8e0a48be2b120276cc15a86d79b8b83cde/lancedb-0.29.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:991043a28c1f49f14df2479b554a95c759a85666dc58573cc86c1b9df05db794", size = 47228009, upload-time = "2026-02-09T06:34:40.872Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bd47bca59a87a88a4ca291a0718291422440750d84b34318048c70a537c2/lancedb-0.29.2-cp39-abi3-win_amd64.whl", hash = "sha256:101eb0ac018bb0b643dd9ea22065f6f2102e9d44c9ac58a197477ccbfbc0b9fa", size = 52028768, upload-time = "2026-02-09T07:00:02.272Z" }, + { url = "https://files.pythonhosted.org/packages/13/2f/1577778ad57dba0c55dc13d87230583e14541c82562483ecf8bb2f8e8a00/lancedb-0.30.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be2a9a43a65c330ccfd08115afb26106cd8d16788522fe7693d3a1f4e01ad321", size = 41959907, upload-time = "2026-03-16T23:03:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/8c2a04ce499a2a97d1a0de2b7e84fa8166f988a9a495e1ada860110489c2/lancedb-0.30.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be6a4ba2a1799a426cbf2ba5ea2559a7389a569e9a31f2409d531ceb59d42f35", size = 43873070, upload-time = "2026-03-16T23:11:01.352Z" }, + { url = "https://files.pythonhosted.org/packages/16/68/e01bf7837454a5ce9e2f6773905e07b09a949bc88136c0773c8166ed7729/lancedb-0.30.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a967ec05f9930770aeb077bc5579769b1bedf559fcd03a592d9644084625918", size = 46891197, upload-time = "2026-03-16T23:14:39.18Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/9085ad17abd98f3a180d7860df3190b2d76f99f533c76d7c7494cec4139d/lancedb-0.30.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:05c66f40f7d4f6f24208e786c40f84b87b1b8e55505305849dd3fed3b78431a3", size = 43877660, upload-time = "2026-03-16T23:11:00.837Z" }, + { url = "https://files.pythonhosted.org/packages/ea/69/504ee25c57c3f23c80276b5b7b5e4c0f98a5197a7e9e51d3c50500d2b53a/lancedb-0.30.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:bdcd27d98554ed11b6f345b14d1307b0e2332d5654767e9ee2e23d9b2d6513d1", size = 46932144, upload-time = "2026-03-16T23:15:00.474Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/d5550f22023e672af1945394f7a06a578fcab2980ecc6666acef3428a771/lancedb-0.30.0-cp39-abi3-win_amd64.whl", hash = "sha256:4751ff0446b90be4d4dccfe05f6c105f403a05f3b8531ab99eedc1c656aca950", size = 51121310, upload-time = "2026-03-16T23:43:23.89Z" }, ] [[package]] name = "langfuse" -version = "3.14.5" +version = "4.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "httpx" }, - { name = "openai" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/6b/7a945e8bc56cbf343b6f6171fd45870b0ea80ea38463b2db8dd5a9dc04a2/langfuse-3.14.5.tar.gz", hash = "sha256:2f543ec1540053d39b08a50ed5992caf1cd54d472a55cb8e5dcf6d4fcb7ff631", size = 235474, upload-time = "2026-02-23T10:42:47.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/d0/6d79ed5614f86f27f5df199cf10c6facf6874ff6f91b828ae4dad90aa86d/langfuse-4.0.6.tar.gz", hash = "sha256:83a6f8cc8f1431fa2958c91e2673bc4179f993297e9b1acd1dbf001785e6cf83", size = 274094, upload-time = "2026-04-01T20:04:15.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/a1/10f04224542d6a57073c4f339b6763836a0899c98966f1d4ffcf56d2cf61/langfuse-3.14.5-py3-none-any.whl", hash = "sha256:5054b1c705ec69bce2d7077ce7419727ac629159428da013790979ca9cae77d5", size = 421240, upload-time = "2026-02-23T10:42:46.085Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/088048e37b6d7ec1b52c6a11bc33101454285a22eaab8303dcccfd78344d/langfuse-4.0.6-py3-none-any.whl", hash = "sha256:0562b1dcf83247f9d8349f0f755eaed9a7f952fee67e66580970f0738bf3adbf", size = 472841, upload-time = "2026-04-01T20:04:16.451Z" }, ] [[package]] @@ -2762,7 +2736,7 @@ wheels = [ [[package]] name = "logfire" -version = "4.25.0" +version = "4.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "executing" }, @@ -2773,9 +2747,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/43/374fc0e6ebe95209414cf743cc693f4ff2ad391fd0712445ed1f63245395/logfire-4.25.0.tar.gz", hash = "sha256:f9a6bf6d40fd3e2c2a86a364617246cadecbde620b4ecccb17c499140f1ebc13", size = 1049745, upload-time = "2026-02-19T15:27:28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/fc/21f923243d8c3ca2ebfa97de46970ced734e66ac634c1c35b6abb41300f1/logfire-4.31.0.tar.gz", hash = "sha256:361bfda17c9d70ada5d220211033bae06b871ddac9d5b06978bc0ceca6b8e658", size = 1080609, upload-time = "2026-03-27T19:00:46.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/cc/a3eb3a5fff27a6bfe2f626624c7c781322151f3228d4ea98c31003dc2d4c/logfire-4.25.0-py3-none-any.whl", hash = "sha256:1865b832e08c58a3fb0d21b24460ee9c6cbeff12db6038c508fb966699ce81c2", size = 298186, upload-time = "2026-02-19T15:27:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/49/1a/8c860e35bf847ac0d647d94bad89dccbb66cbcafdd61d8334f8cc7cfdd58/logfire-4.31.0-py3-none-any.whl", hash = "sha256:49fad38b5e6f199a98e9c8814e860c8a42595bb81479b52a20413e53ee475b72", size = 308896, upload-time = "2026-03-27T19:00:43.107Z" }, ] [package.optional-dependencies] @@ -2788,11 +2762,11 @@ httpx = [ [[package]] name = "logfire-api" -version = "4.25.0" +version = "4.31.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/5c/026cec30d85394aec8f5f12d70edbe2d706837bc9a411bd71a542cedae50/logfire_api-4.25.0.tar.gz", hash = "sha256:7562d5adfe3987291039dddb21947c86cb9d832d068c87d9aa23db86ef07095b", size = 75853, upload-time = "2026-02-19T15:27:29.518Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/8d5a3c1c282d5f2bd9f5e9ddd5288d1414a53301ce389af9016b6d82bd50/logfire_api-4.31.0.tar.gz", hash = "sha256:fc4b01257ebd4ce297ad374ed201eb1a9213b999f6ae6df45cfca5bd0ef378f8", size = 77838, upload-time = "2026-03-27T19:00:47.545Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/39/83414c0fadb4f11f90e6b80b631aa79f62a605664f0c4693e2ebc7ee73f3/logfire_api-4.25.0-py3-none-any.whl", hash = "sha256:0d607eb09ef5426e26f376ff277a8d401bc5b7b4178ea66db404e13c368494cf", size = 120473, upload-time = "2026-02-19T15:27:25.832Z" }, + { url = "https://files.pythonhosted.org/packages/26/27/9372b7492b3e146908d520f8599909311cd930175801ad219171fafc6f3e/logfire_api-4.31.0-py3-none-any.whl", hash = "sha256:3c1f502fd4eb8ef0996427a5cf275fd8f327f38600650a1f53071a8171c812db", size = 123402, upload-time = "2026-03-27T19:00:44.952Z" }, ] [[package]] @@ -2800,8 +2774,8 @@ name = "loguru" version = "0.7.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ @@ -2854,7 +2828,7 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph" }, + { name = "altgraph", marker = "python_full_version < '3.14' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -2866,10 +2840,10 @@ name = "magika" version = "0.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "python-dotenv" }, + { name = "click", marker = "python_full_version < '3.14'" }, + { name = "numpy", marker = "python_full_version < '3.14'" }, + { name = "onnxruntime", marker = "python_full_version < '3.14'" }, + { name = "python-dotenv", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" } wheels = [ @@ -2926,8 +2900,8 @@ name = "markdownify" version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beautifulsoup4" }, - { name = "six" }, + { name = "beautifulsoup4", marker = "python_full_version < '3.14'" }, + { name = "six", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } wheels = [ @@ -2939,12 +2913,12 @@ name = "markitdown" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beautifulsoup4" }, - { name = "charset-normalizer" }, - { name = "defusedxml" }, - { name = "magika" }, - { name = "markdownify" }, - { name = "requests" }, + { name = "beautifulsoup4", marker = "python_full_version < '3.14'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.14'" }, + { name = "defusedxml", marker = "python_full_version < '3.14'" }, + { name = "magika", marker = "python_full_version < '3.14'" }, + { name = "markdownify", marker = "python_full_version < '3.14'" }, + { name = "requests", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } wheels = [ @@ -3111,23 +3085,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/fa/96d4cc7ada2833571 [[package]] name = "mistralai" -version = "1.12.4" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "eval-type-backport" }, { name = "httpx" }, - { name = "invoke" }, + { name = "jsonpath-python" }, { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, { name = "pydantic" }, { name = "python-dateutil" }, - { name = "pyyaml" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/12/c3476c53e907255b5f485f085ba50dd9a84b40fe662e9a888d6ded26fa7b/mistralai-1.12.4.tar.gz", hash = "sha256:e52b53bab58025dcd208eeac13e3c3df5778d4112eeca1f08124096c7738929f", size = 243129, upload-time = "2026-02-20T17:55:13.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d0/229ac07a67a9f4488d13f7a0080471d82f22dc1cdad7bf6b748d27a00d1a/mistralai-2.2.0.tar.gz", hash = "sha256:48abfa247ea5a888400ee294a5c1e090ae7e1445cc8cd008c120ae1e3b8eb9eb", size = 385567, upload-time = "2026-03-31T11:21:38.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl", hash = "sha256:7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6", size = 509321, upload-time = "2026-02-20T17:55:15.27Z" }, + { url = "https://files.pythonhosted.org/packages/db/96/91c8225e1517b728543fcad7bc789391aa22452beb8d88edfaf4111cc9eb/mistralai-2.2.0-py3-none-any.whl", hash = "sha256:9e0cea19eb5281428010c9a220f186e5db0993cfe88f8855ef9d69291956f40b", size = 924713, upload-time = "2026-03-31T11:21:39.761Z" }, ] [[package]] @@ -3170,21 +3142,21 @@ wheels = [ [[package]] name = "mkdocs-get-deps" -version = "0.2.0" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] [[package]] name = "mkdocs-material" -version = "9.7.4" +version = "9.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -3199,9 +3171,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/ce/a1cd02ac7448763f0bb56aaf5f23fa2527944ac6df335080c38c2f253165/mkdocs_material-9.7.4.tar.gz", hash = "sha256:711b0ee63aca9a8c7124d4c73e83a25aa996e27e814767c3a3967df1b9e56f32", size = 4097804, upload-time = "2026-03-03T19:57:36.827Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/94/e3535a9ed078b238df3df75a44694ca0ff5772fd538df4939c658a58c59d/mkdocs_material-9.7.4-py3-none-any.whl", hash = "sha256:6549ad95e4d130ed5099759dfa76ea34c593eefdb9c18c97273605518e99cfbf", size = 9305224, upload-time = "2026-03-03T19:57:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, ] [[package]] @@ -3266,7 +3238,7 @@ wheels = [ [[package]] name = "mknodes" -version = "2.2.12" +version = "2.2.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agentpool" }, @@ -3280,7 +3252,7 @@ dependencies = [ { name = "git-changelog" }, { name = "githarbor" }, { name = "gitpython" }, - { name = "griffe" }, + { name = "griffelib" }, { name = "jinja2" }, { name = "jinjarope", extra = ["icons"] }, { name = "mkdocstrings", extra = ["python"] }, @@ -3297,9 +3269,9 @@ dependencies = [ { name = "yamling" }, { name = "zensical" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/07/7367af609349fb108f16a4bbab6543feb96f0357eea1f60c4194334c5082/mknodes-2.2.12.tar.gz", hash = "sha256:6fdeca7a763a8f2c0ca7d01400368087d0c47ae5b7fc57a5d44ba32279d3f198", size = 342585, upload-time = "2025-12-23T13:37:50.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/4d/0fc0269801c55d3d434b391cd99b518a7e5a59739376b3db23ff6037cd55/mknodes-2.2.14.tar.gz", hash = "sha256:ab572a394121b309c05f572ad96f14e9c6defab5a3f7dfe7e8d95ea8168be4c6", size = 342702, upload-time = "2026-03-06T00:53:15.047Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/16/635a891ef7727fde37b8a4b10f6f5eafdac1e50951b2d94959a9036dda8d/mknodes-2.2.12-py3-none-any.whl", hash = "sha256:930ec8e5cb3cfec1be4f72536aca718621da634b9288e3b3b2fad705a74f7d3b", size = 472519, upload-time = "2025-12-23T13:37:48.646Z" }, + { url = "https://files.pythonhosted.org/packages/83/65/8e0828b326c2b50e95ba5d2dd5e9fe807acf45bdf96482e0d6d09deee8be/mknodes-2.2.14-py3-none-any.whl", hash = "sha256:ce544198ff5fcd29ad6b258877e8cbe76ea86cd9e4fde4fbb3f6afb843681baa", size = 472582, upload-time = "2026-03-06T00:53:11.905Z" }, ] [[package]] @@ -3504,7 +3476,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -3512,21 +3484,30 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/b0089fe7fef0a994ae5ee07029ced0526082c6cfaaa4c10d40a10e33b097/mypy-1.20.0.tar.gz", hash = "sha256:eb96c84efcc33f0b5e0e04beacf00129dd963b67226b01c00b9dfc8affb464c3", size = 3815028, upload-time = "2026-03-31T16:55:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c5/5fe9d8a729dd9605064691816243ae6c49fde0bd28f6e5e17f6a24203c43/mypy-1.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b5dbb55293c1bd27c0fc813a0d2bb5ceef9d65ac5afa2e58f829dab7921fd5", size = 13342047, upload-time = "2026-03-31T16:54:21.555Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9d/d924b38a4923f8d164bf2b4ec98bf13beaf6e10a5348b4b137eadae40a6e/mypy-1.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a79c1eba7ac4209f2d850f0edd0a2f8bba88cbfdfefe6fb76a19e9d4fe5e71a2", size = 14919141, upload-time = "2026-03-31T16:54:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/3b5f2d3e45dc7169b811adce8451679d9430399d03b168f9b0489f43adaa/mypy-1.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39362cdb4ba5f916e7976fccecaab1ba3a83e35f60fa68b64e9a70e221bb2436", size = 14393013, upload-time = "2026-03-31T16:54:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/edc8b0aa145cc09c1c74f7ce2858eead9329931dcbbb26e2ad40906daa4e/mypy-1.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34506397dbf40c15dc567635d18a21d33827e9ab29014fb83d292a8f4f8953b6", size = 15047240, upload-time = "2026-03-31T16:54:31.955Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, ] [package.optional-dependencies] @@ -3552,75 +3533,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - [[package]] name = "nexus-rpc" -version = "1.2.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/50/95d7bc91f900da5e22662c82d9bf0f72a4b01f2a552708bf2f43807707a1/nexus_rpc-1.2.0.tar.gz", hash = "sha256:b4ddaffa4d3996aaeadf49b80dfcdfbca48fe4cb616defaf3b3c5c2c8fc61890", size = 74142, upload-time = "2025-11-17T19:17:06.798Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/04/eaac430d0e6bf21265ae989427d37e94be5e41dc216879f1fbb6c5339942/nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3", size = 28166, upload-time = "2025-11-17T19:17:05.64Z" }, + { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, ] [[package]] name = "numpy" -version = "2.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] [[package]] @@ -3634,35 +3606,35 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.24.2" +version = "1.24.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, + { name = "flatbuffers", marker = "python_full_version < '3.14'" }, + { name = "numpy", marker = "python_full_version < '3.14'" }, + { name = "packaging", marker = "python_full_version < '3.14'" }, + { name = "protobuf", marker = "python_full_version < '3.14'" }, + { name = "sympy", marker = "python_full_version < '3.14'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/98/8f5b9ae63f7f6dd5fb2d192454b915ec966a421fdd0effeeef5be7f7221f/onnxruntime-1.24.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:038ebcd8363c3835ea83eed66129e1d11d8219438892dfb7dc7656c4d4dfa1f9", size = 17217884, upload-time = "2026-02-19T17:13:36.193Z" }, - { url = "https://files.pythonhosted.org/packages/55/e6/dc4dc59565c93506c45017c0dd3f536f6d1b7bc97047821af13fba2e3def/onnxruntime-1.24.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8235cc11e118ad749c497ba93288c04073eccd8cc6cc508c8a7988ae36ab52d8", size = 15026995, upload-time = "2026-02-19T17:13:25.029Z" }, - { url = "https://files.pythonhosted.org/packages/ac/62/6f2851cf3237a91bc04cdb35434293a623d4f6369f79836929600da574ba/onnxruntime-1.24.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92b46cc6d8be4286436a05382a881c88d85a2ae1ea9cfe5e6fab89f2c3e89cc", size = 17106308, upload-time = "2026-02-19T17:14:09.817Z" }, - { url = "https://files.pythonhosted.org/packages/62/5a/1e2b874daf24f26e98af14281fdbdd6ae1ed548ba471c01ea2a3084c55bb/onnxruntime-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:1fd824ee4f6fb811bc47ffec2b25f129f31a087214ca91c8b4f6fda32962b78f", size = 12506095, upload-time = "2026-02-19T17:15:02.434Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6f/8fac5eecb94f861d56a43ede3c2ebcdce60132952d3b72003f3e3d91483c/onnxruntime-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:d8cf0acbf90771fff012c33eb2749e8aca2a8b4c66c672f30ee77c140a6fba5b", size = 12168564, upload-time = "2026-02-19T17:14:52.28Z" }, - { url = "https://files.pythonhosted.org/packages/35/e4/7dfed3f445f7289a0abff709d012439c6c901915390704dd918e5f47aad3/onnxruntime-1.24.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e22fb5d9ac51b61f50cca155ce2927576cc2c42501ede6c0df23a1aeb070bdd5", size = 15036844, upload-time = "2026-02-19T17:13:27.928Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/9d52397e30b0d8c1692afcec5184ca9372ff4d6b0f6039bba9ad479a2563/onnxruntime-1.24.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2956f5220e7be8b09482ae5726caabf78eb549142cdb28523191a38e57fb6119", size = 17117779, upload-time = "2026-02-19T17:14:13.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c8/2321cd06ddbb4321326df365ccb8345cdb4e05643f539729f3943c706e97/onnxruntime-1.24.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:487e3fdedc24bc93f2acdf47c622de49b3999fb5754e7cfa466e5533a0215051", size = 17219405, upload-time = "2026-02-19T17:13:39.925Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ff/a2cdf95d2647f2a5076eb3fc49ae662e375c4eb5c7b6b675f910f96c8e15/onnxruntime-1.24.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c33398bd6ab1a6b7de9410af7360cd8b6312bc0c4848ddb738456c13dfbec4b", size = 15027713, upload-time = "2026-02-19T17:13:30.693Z" }, - { url = "https://files.pythonhosted.org/packages/0d/74/a1913b3a0fc2f27fe1751e9545745a3f35fd7833e3438a4208b4e215778f/onnxruntime-1.24.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2658b3ce6cb33bdeddfcd74c6da509510310717611220cf2106e6c401febabe5", size = 17106108, upload-time = "2026-02-19T17:14:16.619Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bd/fca80d282bca9848b2c8e101c764432dd61a0e9d2377d1c8b3bab13235d0/onnxruntime-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:45b4f68ffec95b2cc0dc96b2b413f69ace9a80a0e5400023c5ac61f73a7a3fdf", size = 12808967, upload-time = "2026-02-19T17:15:05.1Z" }, - { url = "https://files.pythonhosted.org/packages/6d/eb/6b154dd61cac410cacf27a9f53bbf49f4dbfe5b3982f3f5b0247c7bf7b78/onnxruntime-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:6c501aaaaa674e689aaac501e26eb96aba908ebc067fe761fbcbed868bd694a6", size = 12491892, upload-time = "2026-02-19T17:14:54.584Z" }, - { url = "https://files.pythonhosted.org/packages/6f/84/14e5e804836476d3ef6ac07afe3ed6bdf01b69f8ef3ce6ae82c6c80b6d62/onnxruntime-1.24.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5360d3fd9c08ce17fff757759ce4b152852be14d597130f41174d8271f954630", size = 15036834, upload-time = "2026-02-19T17:13:33.65Z" }, - { url = "https://files.pythonhosted.org/packages/3a/27/ecdd3ae7d49d9f54820ededce2d88ddc3333b9ac9bb5f1d0d6aa3148c686/onnxruntime-1.24.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05a2792b5ef9278a89415a1f39d0a22192a872168257100503a5157165a38e7b", size = 17117770, upload-time = "2026-02-19T17:14:20.048Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, + { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] [[package]] name = "openai" -version = "2.24.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3674,9 +3646,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, ] [[package]] @@ -3881,40 +3853,40 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, - { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, - { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, - { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, - { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, - { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, - { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, - { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, ] [[package]] @@ -3982,57 +3954,60 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] [[package]] @@ -4059,11 +4034,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.2" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] @@ -4148,23 +4123,16 @@ wheels = [ [[package]] name = "promptlayer" -version = "1.0.85" +version = "1.0.24" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ably" }, - { name = "aiohttp" }, - { name = "cachetools" }, - { name = "centrifuge-python" }, - { name = "httpx" }, - { name = "nest-asyncio" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "requests" }, - { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ef/9f05ac5d39019f35823c91b0587b2b16d74835d5d51676159d7807334c08/promptlayer-1.0.85.tar.gz", hash = "sha256:4ea61f7d8187f8c5640e6ab642ec0e846e6d94a07be2350e93c043a724cff841", size = 40862, upload-time = "2026-03-05T15:34:32.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/60/d2b60f0fa4d41c7a5480af172bf3a7ff2ebfeb6bed69ec49e951282589a8/promptlayer-1.0.24.tar.gz", hash = "sha256:da9cfc04fd8196bc13c92a6c48452f1e646de665b976278807928dacae3bef47", size = 20772, upload-time = "2024-10-16T20:03:30.957Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/9b/95c61931566ca9a14c9328ff82a1fe8053b54414d48a7e3c25ab37c80726/promptlayer-1.0.85-py3-none-any.whl", hash = "sha256:57ebb7b129992e730c00b6e5406781689a0697cad268ffca62105e1cb183b90e", size = 46466, upload-time = "2026-03-05T15:34:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/a2/54798162fa79ca69098de8b78a8cffa3e3a58e199234a16693e266c65dbb/promptlayer-1.0.24-py3-none-any.whl", hash = "sha256:06de6cbf02861b59425a9221526284c69006c1d4f2d6f429f561ea9190d3bba7", size = 23063, upload-time = "2024-10-16T20:03:29.141Z" }, ] [[package]] @@ -4238,17 +4206,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -4395,11 +4363,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.2" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, ] [[package]] @@ -4457,19 +4425,19 @@ email = [ [[package]] name = "pydantic-ai" -version = "1.66.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai", "xai"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/82/33235564d214273ded8c0f9686060b819c7aec19c7b2d9b86b40e69e5768/pydantic_ai-1.66.0.tar.gz", hash = "sha256:85db3e1b417cd95c6495b1c150cc4ea70fac0f585fd45d4e64178556992aea2a", size = 12132, upload-time = "2026-03-05T00:54:56.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/96/aaaadefd68960fc5bde4f3cef6555ee0a33156599c8d08b123b5571d8b2e/pydantic_ai-1.75.0.tar.gz", hash = "sha256:06cbe1843a3a584ba071e7ed9219a2637ab158a6d6c4df5e4163d0a396358c0e", size = 12659, upload-time = "2026-04-01T00:38:20.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/3d/ae0262b9433ad97640c9ce2bd07a5beb74c1826ce146087df6a1c6018a34/pydantic_ai-1.66.0-py3-none-any.whl", hash = "sha256:5bea3e7ef277226dddc0734976ef046ecd302ed187643ce28c79eb9718eeb448", size = 7228, upload-time = "2026-03-05T00:54:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/713a9ac62072c1c63ce624cd3d017d292d3300dc888ab08335915422036f/pydantic_ai-1.75.0-py3-none-any.whl", hash = "sha256:9e267c7e86b5f77f9e9e2cd3b3d658f89905e504716da55f91fe4cbea1cb1a17", size = 7551, upload-time = "2026-04-01T00:38:11.78Z" }, ] [[package]] name = "pydantic-ai-slim" -version = "1.66.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, @@ -4480,9 +4448,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/31/1b291e2c169c684290b458a1333d438e34c542d355c60c0bc92866c192a2/pydantic_ai_slim-1.66.0.tar.gz", hash = "sha256:d675f3cf7171c7ea767084a2228d7a2e8eb88e18bfefba71387ed150fcb64069", size = 435408, upload-time = "2026-03-05T00:54:58.587Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/38/79478740dad656fcf8d293ef2d43dfbb7b6b8edec7cc5ce36fbbf26bc28f/pydantic_ai_slim-1.75.0.tar.gz", hash = "sha256:5132e7fc135e062cde13ecd389940bccd84feadabd4ed810f61a099068d271a7", size = 504543, upload-time = "2026-04-01T00:38:23.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/c9/098d675eb20863c6c92a23e09b6cc0d10df3f96191f04f3daefb31f180bc/pydantic_ai_slim-1.66.0-py3-none-any.whl", hash = "sha256:59dcccbcbf948d356dd4a03457962b4079db42c56edf8a11113d827015027e66", size = 566105, upload-time = "2026-03-05T00:54:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/15b05d411e1a08d894f0a0f6c56632930f6852cee282ef92e4dde9533021/pydantic_ai_slim-1.75.0-py3-none-any.whl", hash = "sha256:8ba5ad332be6c2f8c62e0778504086a2d9441cc28064bb335a3caf6d270b463f", size = 646340, upload-time = "2026-04-01T00:38:14.842Z" }, ] [package.optional-dependencies] @@ -4500,6 +4468,7 @@ cli = [ { name = "argcomplete" }, { name = "prompt-toolkit" }, { name = "pyperclip" }, + { name = "pyyaml" }, { name = "rich" }, ] cohere = [ @@ -4536,6 +4505,10 @@ openai = [ retries = [ { name = "tenacity" }, ] +spec = [ + { name = "pydantic-handlebars" }, + { name = "pyyaml" }, +] temporal = [ { name = "temporalio" }, ] @@ -4605,7 +4578,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.66.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4615,14 +4588,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/9d/eda010d4efad2b52f7943b53bab61a7bad561b789811d6829ceea40d1c96/pydantic_evals-1.66.0.tar.gz", hash = "sha256:0e204e19262f6de82462e9ab9b6558979db742c47832b08873a8c002ef32ced8", size = 56693, upload-time = "2026-03-05T00:55:00.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/dd/109412f597d278ac2ac19c9bae27ce2916b2db8382539849b92e32231220/pydantic_evals-1.75.0.tar.gz", hash = "sha256:789ef1a52af6bf5b7a2ad48490f04925f59ebccb5844ae795b94d190a6f5927e", size = 65846, upload-time = "2026-04-01T00:38:24.498Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/f2/689ab9670af6ad039994ccde34d71703d2bda2582a819d8b849a814c2d57/pydantic_evals-1.66.0-py3-none-any.whl", hash = "sha256:53a84b9dff8868c65866c2fed397de600bed2df11f471d5b3d8e3a9c0e5ef93b", size = 67602, upload-time = "2026-03-05T00:54:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cc/a92b5da973567eda70a61d035ebb7a69de613945078b03dc57f7823de667/pydantic_evals-1.75.0-py3-none-any.whl", hash = "sha256:65c06b7e7ded266d7e754cd03782e76002f80639c079f9d1b834a7c7117de8b4", size = 77739, upload-time = "2026-04-01T00:38:16.77Z" }, ] [[package]] name = "pydantic-graph" -version = "1.66.0" +version = "1.75.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4630,9 +4603,21 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/5e/4a3ed6c4047fd2676b248cee3666299b6214f691c086fd5f9bdda96ace1d/pydantic_graph-1.66.0.tar.gz", hash = "sha256:834df5137098c2c95d2241b98d4dd61af4a3ff24784751c82cc543db46dd29f5", size = 58522, upload-time = "2026-03-05T00:55:01.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/4c/7d6e07ad9affc781201a8ca0a59e655952403f0e14416b2563d4483c1a4c/pydantic_graph-1.75.0.tar.gz", hash = "sha256:c46feb2a0d0e87a4487324ea91e5e547114996bd4026542eebddcaee3e4989bd", size = 58713, upload-time = "2026-04-01T00:38:25.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/95/22c0ad3f3830d7fdd4dbfdc78548705f6c9ac434ada0d790ffc02491b39e/pydantic_graph-1.66.0-py3-none-any.whl", hash = "sha256:8f75d34efbaa4b65767d39faa2b3270fd321fb4104a66d3773754f4854876739", size = 72351, upload-time = "2026-03-05T00:54:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/29992d327932faaa511de8fbcf58f75171a70de0986d3aed21fe93888be8/pydantic_graph-1.75.0-py3-none-any.whl", hash = "sha256:ea290452de13477699fe60745fe70f01ae3d16c5e50457e0b3d65a1ea1c9c703", size = 72502, upload-time = "2026-04-01T00:38:18.118Z" }, +] + +[[package]] +name = "pydantic-handlebars" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/16/d41768bd3fd77e6250c20be11a3e68fee5fff07c3356455e6708f6a60f2a/pydantic_handlebars-0.1.0.tar.gz", hash = "sha256:1931c54946add1b5e3796c9bf6a005ed7662cef0109bb05c352f0b3d031a1260", size = 159826, upload-time = "2026-03-01T20:00:17.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5f/86b1630be61bdebf253c2f953a6c3f073ec21bb0725565ea3896802e1ca3/pydantic_handlebars-0.1.0-py3-none-any.whl", hash = "sha256:8a436fe8bc607295eb04bec58bd6e2c9498c9e069c557ff0b505e3d568c783bc", size = 40890, upload-time = "2026-03-01T20:00:16.106Z" }, ] [[package]] @@ -4651,11 +4636,11 @@ wheels = [ [[package]] name = "pydocket" -version = "0.18.0" +version = "0.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cloudpickle" }, - { name = "croniter" }, + { name = "cronsim" }, { name = "fakeredis", extra = ["lua"] }, { name = "opentelemetry-api" }, { name = "prometheus-client" }, @@ -4668,30 +4653,18 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/da/5f76e42214c76402e1a2b4b59610211635c1068cab85509c78f1ca49a385/pydocket-0.18.0.tar.gz", hash = "sha256:cd5b6e7386331ca05a0163401f392b08b07e61342b5333c3ece6a7ca5435f984", size = 354637, upload-time = "2026-03-02T16:22:17.356Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/57/ac0d47cd3550d859138647c2c4fbd53a2db05db8729433eaa6128e9964ba/pydocket-0.18.0-py3-none-any.whl", hash = "sha256:d995d9a3c88af0402fda640c18e1b51561041b9e3af1a92dce2fdc6c8f6c7090", size = 98848, upload-time = "2026-03-02T16:22:15.792Z" }, -] - -[[package]] -name = "pyee" -version = "13.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/5f/82dde9fb6099b960a4203596d3b755d1bd2c0d0210fea104d015d6515d7f/pydocket-0.18.2.tar.gz", hash = "sha256:cc2051d15557f83bb164a83b0743fa9c12c2bfe9a9145cff3a5922b4935ce4f5", size = 354762, upload-time = "2026-03-10T13:09:22.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/8c1b6340baf81d7f6c97fe0181bda7cfd500d5e33bf469fbffbdae07b3c9/pydocket-0.18.2-py3-none-any.whl", hash = "sha256:19e48de15e83370f750e362610b777533ff9c0fa48bf36766ed581f91d266556", size = 99041, upload-time = "2026-03-10T13:09:20.598Z" }, ] [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -4724,15 +4697,15 @@ wheels = [ [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.2" +version = "2026.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f3b30d72b89ab5b8f3ef714db94d09c7c263cce6562b4c7c636d99630695/pyinstaller_hooks_contrib-2026.2.tar.gz", hash = "sha256:cbd1eb00b5d13301b1cce602e1fffb17f0c531c0391f0a87a383d376be68a186", size = 171884, upload-time = "2026-03-02T23:07:01.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/fe/9278c29394bf69169febc21f96b4252c3ee7c8ec22c2fc545004bed47e71/pyinstaller_hooks_contrib-2026.4.tar.gz", hash = "sha256:766c281acb1ecc32e21c8c667056d7ebf5da0aabd5e30c219f9c2a283620eeaa", size = 173050, upload-time = "2026-03-31T14:10:51.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/3b/1efef5ff4d4d150f646b873e963437c0b800cb375a37df01fefab149f4d9/pyinstaller_hooks_contrib-2026.2-py3-none-any.whl", hash = "sha256:fc29f0481b58adf78ce9c1d9cf135fe96f38c708f74b2aa0670ef93e59578ab9", size = 453939, upload-time = "2026-03-02T23:06:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/88/f4/035fb8c06deff827f540a9a4ed9122c54e5376fca3e42eddf0c263730775/pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f", size = 455496, upload-time = "2026-03-31T14:10:49.867Z" }, ] [[package]] @@ -4769,11 +4742,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -4783,24 +4756,24 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "10.21" +version = "10.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, ] [[package]] name = "pypdf" -version = "6.7.5" +version = "6.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, ] [[package]] @@ -4861,16 +4834,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -4947,11 +4920,11 @@ wheels = [ [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] @@ -4977,15 +4950,15 @@ wheels = [ [[package]] name = "python-telegram-bot" -version = "22.6" +version = "22.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpcore", marker = "python_full_version >= '3.14'" }, { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/9b/8df90c85404166a6631e857027866263adb27440d8af1dbeffbdc4f0166c/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742", size = 1503761, upload-time = "2026-01-24T13:57:00.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/97/7298f0e1afe3a1ae52ff4c5af5087ed4de319ea73eb3b5c8c4dd4e76e708/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0", size = 737267, upload-time = "2026-01-24T13:56:58.06Z" }, + { url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" }, ] [package.optional-dependencies] @@ -5111,11 +5084,11 @@ wheels = [ [[package]] name = "redis" -version = "7.2.1" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, ] [[package]] @@ -5133,74 +5106,74 @@ wheels = [ [[package]] name = "regex" -version = "2026.2.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, - { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, - { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, - { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, - { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, - { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, - { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, - { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, - { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, - { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, - { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, - { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, - { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, - { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, - { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, - { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, - { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, - { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, - { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, - { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, - { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, - { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, - { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, - { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, - { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, - { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, - { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, - { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, - { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, - { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, - { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, - { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +version = "2026.3.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/93/5ab3e899c47fa7994e524447135a71cd121685a35c8fe35029005f8b236f/regex-2026.3.32.tar.gz", hash = "sha256:f1574566457161678297a116fa5d1556c5a4159d64c5ff7c760e7c564bf66f16", size = 415605, upload-time = "2026-03-28T21:49:22.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/ba/9c1819f302b42b5fbd4139ead6280e9ec37d19bbe33379df0039b2a57bb4/regex-2026.3.32-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b", size = 490394, upload-time = "2026-03-28T21:46:58.112Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/f62b0ce79eb83ca82fffea1736289d29bc24400355968301406789bcebd2/regex-2026.3.32-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb", size = 291993, upload-time = "2026-03-28T21:47:00.198Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/ba0f8f81f88cd20c0b27acc123561ac5495ea33f800f0b8ebed2038b23eb/regex-2026.3.32-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b", size = 289618, upload-time = "2026-03-28T21:47:02.269Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0d/b47a0e68bc511c195ff129c0311a4cd79b954b8676193a9d03a97c623a91/regex-2026.3.32-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a", size = 796427, upload-time = "2026-03-28T21:47:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/32b05aa8fde7789ba316533c0f30e87b6b5d38d6d7f8765eadc5aab84671/regex-2026.3.32-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4", size = 865850, upload-time = "2026-03-28T21:47:05.982Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/828d8095501f237b83f630d4069eea8c0e5cb6a204e859cf0b67c223ce12/regex-2026.3.32-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e", size = 913578, upload-time = "2026-03-28T21:47:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f8/acf1eb80f58852e85bd39a6ddfa78ce2243ddc8de8da7582e6ba657da593/regex-2026.3.32-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173", size = 801536, upload-time = "2026-03-28T21:47:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/986cdf8d12693451f5889aaf4ea4f65b2c49b1152ae814fa1fb75439e40b/regex-2026.3.32-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae", size = 776226, upload-time = "2026-03-28T21:47:12.891Z" }, + { url = "https://files.pythonhosted.org/packages/32/02/945a6a2348ca1c6608cb1747275c8affd2ccd957d4885c25218a86377912/regex-2026.3.32-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55", size = 785933, upload-time = "2026-03-28T21:47:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/53/12/c5bab6cc679ad79a45427a98c4e70809586ac963c5ad54a9217533c4763e/regex-2026.3.32-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e", size = 860671, upload-time = "2026-03-28T21:47:16.985Z" }, + { url = "https://files.pythonhosted.org/packages/bf/68/8d85f98c2443469facabef62b82b851d369b13f92bec2ca7a3808deaa47b/regex-2026.3.32-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897", size = 765335, upload-time = "2026-03-28T21:47:18.872Z" }, + { url = "https://files.pythonhosted.org/packages/89/a7/d8a9c270916107a501fca63b748547c6c77e570d19f16a29b557ce734f3d/regex-2026.3.32-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f", size = 851913, upload-time = "2026-03-28T21:47:20.793Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/03d392b26679914ccf21f83d18ad4443232d2f8c3e2c30a962d4e3918d9c/regex-2026.3.32-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4", size = 788447, upload-time = "2026-03-28T21:47:22.628Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/692227d23535a50604333068b39eb262626db780ab1e1b19d83fc66853aa/regex-2026.3.32-cp313-cp313-win32.whl", hash = "sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b", size = 266834, upload-time = "2026-03-28T21:47:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/13e4e56adc16ba607cffa1fe880f233eb9ded8ab8a8580619683c9e4ce48/regex-2026.3.32-cp313-cp313-win_amd64.whl", hash = "sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb", size = 277972, upload-time = "2026-03-28T21:47:26.627Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1c/80a86dbb2b416fec003b1801462bdcebbf1d43202ed5acb176e99c1ba369/regex-2026.3.32-cp313-cp313-win_arm64.whl", hash = "sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14", size = 270649, upload-time = "2026-03-28T21:47:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/e38372da599dc1c39c599907ec535016d110034bd3701ce36554f59767ef/regex-2026.3.32-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e", size = 494495, upload-time = "2026-03-28T21:47:30.642Z" }, + { url = "https://files.pythonhosted.org/packages/5f/27/6e29ece8c9ce01001ece1137fa21c8707529c2305b22828f63623b0eb262/regex-2026.3.32-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0", size = 293988, upload-time = "2026-03-28T21:47:32.553Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/8752e18bb87a2fe728b73b0f83c082eb162a470766063f8028759fb26844/regex-2026.3.32-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c", size = 292634, upload-time = "2026-03-28T21:47:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7b/d7729fe294e23e9c7c3871cb69d49059fa7d65fd11e437a2cbea43f6615d/regex-2026.3.32-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d", size = 810532, upload-time = "2026-03-28T21:47:36.839Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/4dae7b000659f611b17b9c1541fba800b0569e4060debc4635ef1b23982c/regex-2026.3.32-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc", size = 871919, upload-time = "2026-03-28T21:47:39.192Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/aa8ad3977b9399861db3df62b33fe5fef6932ee23a1b9f4f357f58f2094b/regex-2026.3.32-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f", size = 916550, upload-time = "2026-03-28T21:47:41.618Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/6379d7f5b59ff0656ba49cf666d5013ecee55e83245275b310b0ffc79143/regex-2026.3.32-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98", size = 814988, upload-time = "2026-03-28T21:47:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/2c/af/2dfddc64074bd9b70e27e170ee9db900542e2870210b489ad4471416ba86/regex-2026.3.32-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9", size = 786337, upload-time = "2026-03-28T21:47:46.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2f/4eb8abd705236402b4fe0e130971634deffb1855e2028bf02a2b7c0e841c/regex-2026.3.32-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5", size = 800029, upload-time = "2026-03-28T21:47:48.356Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/77d9ca2c9df483b51b4b1291c96d79c9ae301077841c4db39bc822f6b4c6/regex-2026.3.32-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf", size = 865843, upload-time = "2026-03-28T21:47:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/306f477a509f4eed699071b1f031d89edd5a2b5fa28c8ede5b2638eaba82/regex-2026.3.32-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810", size = 772473, upload-time = "2026-03-28T21:47:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f6/54bd83ec46ac037de2beb049afc9dd5d2769c6ecaadf7856254ce610e62a/regex-2026.3.32-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b", size = 856805, upload-time = "2026-03-28T21:47:55.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/ee0e7d14de1fc6582d5782f072db6c61465a38a4142f88e175dda494b536/regex-2026.3.32-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60", size = 801875, upload-time = "2026-03-28T21:47:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/8a/06/0fa9daca59d07b6aabd8e0468d3b86fd578576a157206fbcddbfc2298f7d/regex-2026.3.32-cp313-cp313t-win32.whl", hash = "sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85", size = 269892, upload-time = "2026-03-28T21:47:59.674Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/77f16b5ad9f10ca574f03d84a354b359b0ac33f85054f2f2daafc9f7b807/regex-2026.3.32-cp313-cp313t-win_amd64.whl", hash = "sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865", size = 281318, upload-time = "2026-03-28T21:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/db4446faaea8d01c8315c9c89c7dc6abbb3305e8e712e9b23936095c4d58/regex-2026.3.32-cp313-cp313t-win_arm64.whl", hash = "sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4", size = 272366, upload-time = "2026-03-28T21:48:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, + { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, + { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, ] [[package]] @@ -5267,7 +5240,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -5275,9 +5248,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -5423,18 +5396,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, -] - [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -5446,27 +5407,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -5505,17 +5466,17 @@ wheels = [ [[package]] name = "schemez" -version = "2.2.27" +version = "2.2.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, - { name = "griffe" }, + { name = "griffelib" }, { name = "pydantic" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a9/f9864dab8e99b84a400238c00b9a9b3260dd195caeb6842091329f7d1016/schemez-2.2.27.tar.gz", hash = "sha256:8455a9f3947d23fbf8cd84a7c435bb93681c77f421bf0d4648a68f11afdb961b", size = 75807, upload-time = "2026-01-10T05:04:30.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/f9/394ef53eca56a08448feebd4645fc1f10719e9286faaaf19b02277ec03be/schemez-2.2.29.tar.gz", hash = "sha256:801fd18eeda003c061f43d1d8943fe206fd3d64dc2da0531bda49334c9056579", size = 76147, upload-time = "2026-03-16T01:02:52.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/a7/90c2cf4bc958e50606de04c40fac7a3699ab2a5b1f4dc799ce54fffbfe8d/schemez-2.2.27-py3-none-any.whl", hash = "sha256:76234030037aa3bd7feb57b2668d504f80494da015ebe583e215c28a0d97a33d", size = 93984, upload-time = "2026-01-10T05:04:28.702Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/086c3f07ba3a0bbe13e553e59666bba49e7393f3a9cb451e6dc3867c2ea8/schemez-2.2.29-py3-none-any.whl", hash = "sha256:7517451ee5c0a8d3b3ebcd5327622ac3c8e9b09e3f7fad476c5ffd9e78f16d47", size = 94033, upload-time = "2026-03-16T01:02:51.055Z" }, ] [package.optional-dependencies] @@ -5550,8 +5511,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version < '3.14' or sys_platform != 'win32'" }, + { name = "jeepney", marker = "python_full_version < '3.14' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -5569,11 +5530,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] [[package]] @@ -5596,11 +5557,11 @@ wheels = [ [[package]] name = "slack-sdk" -version = "3.40.1" +version = "3.41.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/18/784859b33a3f9c8cdaa1eda4115eb9fe72a0a37304718887d12991eeb2fd/slack_sdk-3.40.1.tar.gz", hash = "sha256:a215333bc251bc90abf5f5110899497bf61a3b5184b6d9ee35d73ebf09ec3fd0", size = 250379, upload-time = "2026-02-18T22:11:01.819Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/e1/bb81f93c9f403e3b573c429dd4838ec9b44e4ef35f3b0759eb49557ab6e3/slack_sdk-3.40.1-py2.py3-none-any.whl", hash = "sha256:cd8902252979aa248092b0d77f3a9ea3cc605bc5d53663ad728e892e26e14a65", size = 313687, upload-time = "2026-02-18T22:11:00.027Z" }, + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, ] [[package]] @@ -5631,11 +5592,11 @@ wheels = [ [[package]] name = "smmap" -version = "5.0.2" +version = "5.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] [[package]] @@ -5751,15 +5712,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.2" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, ] [[package]] @@ -5772,14 +5733,14 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] @@ -5847,7 +5808,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath" }, + { name = "mpmath", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -5877,7 +5838,7 @@ wheels = [ [[package]] name = "temporalio" -version = "1.20.0" +version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, @@ -5885,13 +5846,13 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/db/7d5118d28b0918888e1ec98f56f659fdb006351e06d95f30f4274962a76f/temporalio-1.20.0.tar.gz", hash = "sha256:5a6a85b7d298b7359bffa30025f7deac83c74ac095a4c6952fbf06c249a2a67c", size = 1850498, upload-time = "2025-11-25T21:25:20.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b1/7d9b3104ab7994e7d49e765b92495aaff44810b1e066c874c284a93ebd55/temporalio-1.24.0.tar.gz", hash = "sha256:e534e2e71b4a721193ec4ff3dae521146d093554bd47a64f5605d4ca33e56718", size = 2040485, upload-time = "2026-03-23T15:33:33.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/1b/e69052aa6003eafe595529485d9c62d1382dd5e671108f1bddf544fb6032/temporalio-1.20.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:fba70314b4068f8b1994bddfa0e2ad742483f0ae714d2ef52e63013ccfd7042e", size = 12061638, upload-time = "2025-11-25T21:24:57.918Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/3e8c67ed7f23bedfa231c6ac29a7a9c12b89881da7694732270f3ecd6b0c/temporalio-1.20.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffc5bb6cabc6ae67f0bfba44de6a9c121603134ae18784a2ff3a7f230ad99080", size = 11562603, upload-time = "2025-11-25T21:25:01.721Z" }, - { url = "https://files.pythonhosted.org/packages/6d/be/ed0cc11702210522a79e09703267ebeca06eb45832b873a58de3ca76b9d0/temporalio-1.20.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e80c1e4cdf88fa8277177f563edc91466fe4dc13c0322f26e55c76b6a219e6", size = 11824016, upload-time = "2025-11-25T21:25:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/09c5cafabc80139d97338a2bdd8ec22e08817dfd2949ab3e5b73565006eb/temporalio-1.20.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba92d909188930860c9d89ca6d7a753bc5a67e4e9eac6cea351477c967355eed", size = 12189521, upload-time = "2025-11-25T21:25:12.091Z" }, - { url = "https://files.pythonhosted.org/packages/11/23/5689c014a76aff3b744b3ee0d80815f63b1362637814f5fbb105244df09b/temporalio-1.20.0-cp310-abi3-win_amd64.whl", hash = "sha256:eacfd571b653e0a0f4aa6593f4d06fc628797898f0900d400e833a1f40cad03a", size = 12745027, upload-time = "2025-11-25T21:25:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/30517c21d6155bce1c3dc0e420db48da0231230dbc683f40ab6d5fe22b37/temporalio-1.24.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7f11e7b4f4d09bafba499b43188353e23dc128b1fe3f3160014476e3dce70760", size = 12223918, upload-time = "2026-03-23T15:33:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/73/d0/11aa103bde794524008c1850a84e06cde98698395ca1f8b12e1bd2390aa8/temporalio-1.24.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5cff75a0ca922575b808a7fca1b0de38f6eea061f49e026664b8be9d5bb06ab8", size = 11708887, upload-time = "2026-03-23T15:33:11.67Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/774b56100e6bb94e3757ec96fb5c2bc62d42defc7d6de0ee35a12273827a/temporalio-1.24.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee7c13b6724dd0c304aa846aecf6da72a8550f4ade40a0a7f6dcc1c92ef35710", size = 12028303, upload-time = "2026-03-23T15:33:18.022Z" }, + { url = "https://files.pythonhosted.org/packages/e5/91/c05d0e9c2432fe8b1ea0d6fae321866ee49a320ad5e494e6ec9424ca5c28/temporalio-1.24.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa71b9bfa42f951dd04ade97ce7f92ecedee8903047b4b41b122bb8cbd87a337", size = 12375155, upload-time = "2026-03-23T15:33:24.234Z" }, + { url = "https://files.pythonhosted.org/packages/c4/97/5c939e4609c164c8690a3b5a135eb828d531de8ef63ff447a2a439c0b0fb/temporalio-1.24.0-cp310-abi3-win_amd64.whl", hash = "sha256:52f6833647eceddbebcc376e2ea663a9f73b2b3a42675f503aeb27c98fd4daeb", size = 12720174, upload-time = "2026-03-23T15:33:30.826Z" }, ] [[package]] @@ -6068,21 +6029,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/6a/210a302e8025ac492cbaea58d3720d66b7d8034c5d747ac5e4d2d235aa25/tree_sitter_c-0.24.1-cp310-abi3-win_arm64.whl", hash = "sha256:d46bbda06f838c2dcb91daf767813671fd366b49ad84ff37db702129267b46e1", size = 82715, upload-time = "2025-05-24T17:32:57.248Z" }, ] -[[package]] -name = "tree-sitter-c-sharp" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/85/a61c782afbb706a47d990eaee6977e7c2bd013771c5bf5c81c617684f286/tree_sitter_c_sharp-0.23.1.tar.gz", hash = "sha256:322e2cfd3a547a840375276b2aea3335fa6458aeac082f6c60fec3f745c967eb", size = 1317728, upload-time = "2024-11-11T05:25:32.535Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/04/f6c2df4c53a588ccd88d50851155945cff8cd887bd70c175e00aaade7edf/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd", size = 372235, upload-time = "2024-11-11T05:25:19.424Z" }, - { url = "https://files.pythonhosted.org/packages/99/10/1aa9486f1e28fc22810fa92cbdc54e1051e7f5536a5e5b5e9695f609b31e/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e", size = 419046, upload-time = "2024-11-11T05:25:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/0f/21/13df29f8fcb9ba9f209b7b413a4764b673dfd58989a0dd67e9c7e19e9c2e/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d", size = 415999, upload-time = "2024-11-11T05:25:22.359Z" }, - { url = "https://files.pythonhosted.org/packages/ca/72/fc6846795bcdae2f8aa94cc8b1d1af33d634e08be63e294ff0d6794b1efc/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8024e466b2f5611c6dc90321f232d8584893c7fb88b75e4a831992f877616d2", size = 402830, upload-time = "2024-11-11T05:25:24.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3a/b6028c5890ce6653807d5fa88c72232c027c6ceb480dbeb3b186d60e5971/tree_sitter_c_sharp-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7f9bf876866835492281d336b9e1f9626ab668737f74e914c31d285261507da7", size = 397880, upload-time = "2024-11-11T05:25:25.937Z" }, - { url = "https://files.pythonhosted.org/packages/47/d2/4facaa34b40f8104d8751746d0e1cd2ddf0beb9f1404b736b97f372bd1f3/tree_sitter_c_sharp-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:ae9a9e859e8f44e2b07578d44f9a220d3fa25b688966708af6aa55d42abeebb3", size = 377562, upload-time = "2024-11-11T05:25:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/d8/88/3cf6bd9959d94d1fec1e6a9c530c5f08ff4115a474f62aedb5fedb0f7241/tree_sitter_c_sharp-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:c81548347a93347be4f48cb63ec7d60ef4b0efa91313330e69641e49aa5a08c5", size = 375157, upload-time = "2024-11-11T05:25:30.839Z" }, -] - [[package]] name = "tree-sitter-cpp" version = "0.23.4" @@ -6098,22 +6044,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/6a/65435d4d1f4c735be7ffe52d7c2e7b8a7f7c2790343a2719c60c548611c8/tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281", size = 288203, upload-time = "2024-11-11T06:59:23.404Z" }, ] -[[package]] -name = "tree-sitter-embedded-template" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/a7/77729fefab8b1b5690cfc54328f2f629d1c076d16daf32c96ba39d3a3a3a/tree_sitter_embedded_template-0.25.0.tar.gz", hash = "sha256:7d72d5e8a1d1d501a7c90e841b51f1449a90cc240be050e4fb85c22dab991d50", size = 14114, upload-time = "2025-08-29T00:42:51.078Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/9d/3e3c8ee0c019d3bace728300a1ca807c03df39e66cc51e9a5e7c9d1e1909/tree_sitter_embedded_template-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fa0d06467199aeb33fb3d6fa0665bf9b7d5a32621ffdaf37fd8249f8a8050649", size = 10266, upload-time = "2025-08-29T00:42:44.148Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ab/6d4e43b736b2a895d13baea3791dc8ce7245bedf4677df9e7deb22e23a2a/tree_sitter_embedded_template-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc7aacbc2985a5d7e7fe7334f44dffe24c38fb0a8295c4188a04cf21a3d64a73", size = 10650, upload-time = "2025-08-29T00:42:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/9f/97/ea3d1ea4b320fe66e0468b9f6602966e544c9fe641882484f9105e50ee0c/tree_sitter_embedded_template-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7c88c3dd8b94b3c9efe8ae071ff6b1b936a27ac5f6e651845c3b9631fa4c1c2", size = 18268, upload-time = "2025-08-29T00:42:46.03Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/0f42ca894a8f7c298cf336080046ccc14c10e8f4ea46d455f640193181b2/tree_sitter_embedded_template-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:025f7ca84218dcd8455efc901bdbcc2689fb694f3a636c0448e322a23d4bc96b", size = 19068, upload-time = "2025-08-29T00:42:46.699Z" }, - { url = "https://files.pythonhosted.org/packages/d0/2a/0b720bcae7c2dd0a44889c09e800a2f8eb08c496dede9f2b97683506c4c3/tree_sitter_embedded_template-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b5dc1aef6ffa3fae621fe037d85dd98948b597afba20df29d779c426be813ee5", size = 18518, upload-time = "2025-08-29T00:42:47.694Z" }, - { url = "https://files.pythonhosted.org/packages/14/8a/d745071afa5e8bdf5b381cf84c4dc6be6c79dee6af8e0ff07476c3d8e4aa/tree_sitter_embedded_template-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d0a35cfe634c44981a516243bc039874580e02a2990669313730187ce83a5bc6", size = 18267, upload-time = "2025-08-29T00:42:48.635Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/728355e594fca140f793f234fdfec195366b6956b35754d00ea97ca18b21/tree_sitter_embedded_template-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:3e05a4ac013d54505e75ae48e1a0e9db9aab19949fe15d9f4c7345b11a84a069", size = 13049, upload-time = "2025-08-29T00:42:49.589Z" }, - { url = "https://files.pythonhosted.org/packages/d8/de/afac475e694d0e626b0808f3c86339c349cd15c5163a6a16a53cc11cf892/tree_sitter_embedded_template-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:2751d402179ac0e83f2065b249d8fe6df0718153f1636bcb6a02bde3e5730db9", size = 11978, upload-time = "2025-08-29T00:42:50.226Z" }, -] - [[package]] name = "tree-sitter-go" version = "0.25.0" @@ -6163,21 +6093,16 @@ wheels = [ [[package]] name = "tree-sitter-language-pack" -version = "0.13.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tree-sitter" }, - { name = "tree-sitter-c-sharp" }, - { name = "tree-sitter-embedded-template" }, - { name = "tree-sitter-yaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/83/d1bc738d6f253f415ee54a8afb99640f47028871436f53f2af637c392c4f/tree_sitter_language_pack-0.13.0.tar.gz", hash = "sha256:032034c5e27b1f6e00730b9e7c2dbc8203b4700d0c681fd019d6defcf61183ec", size = 51353370, upload-time = "2025-11-26T14:01:04.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/38/aec1f450ae5c4796de8345442f297fcf8912c7d2e00a66d3236ff0f825ed/tree_sitter_language_pack-0.13.0-cp310-abi3-macosx_10_15_universal2.whl", hash = "sha256:0e7eae812b40a2dc8a12eb2f5c55e130eb892706a0bee06215dd76affeb00d07", size = 32991857, upload-time = "2025-11-26T14:00:51.459Z" }, - { url = "https://files.pythonhosted.org/packages/90/09/11f51c59ede786dccddd2d348d5d24a1d99c54117d00f88b477f5fae4bd5/tree_sitter_language_pack-0.13.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:7fdacf383418a845b20772118fcb53ad245f9c5d409bd07dae16acec65151756", size = 20092989, upload-time = "2025-11-26T14:00:54.202Z" }, - { url = "https://files.pythonhosted.org/packages/72/9d/644db031047ab1a70fc5cb6a79a4d4067080fac628375b2320752d2d7b58/tree_sitter_language_pack-0.13.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:0d4f261fce387ae040dae7e4d1c1aca63d84c88320afcc0961c123bec0be8377", size = 19952029, upload-time = "2025-11-26T14:00:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/48/92/5fd749bbb3f5e4538492c77de7bc51a5e479fec6209464ddc25be9153b13/tree_sitter_language_pack-0.13.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:78f369dc4d456c5b08d659939e662c2f9b9fba8c0ec5538a1f973e01edfcf04d", size = 19944614, upload-time = "2025-11-26T14:00:59.381Z" }, - { url = "https://files.pythonhosted.org/packages/97/59/2287f07723c063475d6657babed0d5569f4b499e393ab51354d529c3e7b5/tree_sitter_language_pack-0.13.0-cp310-abi3-win_amd64.whl", hash = "sha256:1cdbc88a03dacd47bec69e56cc20c48eace1fbb6f01371e89c3ee6a2e8f34db1", size = 16896852, upload-time = "2025-11-26T14:01:01.788Z" }, + { url = "https://files.pythonhosted.org/packages/98/16/af40a9bd3d50c4a342cb942c21998813342e4969eb45d9dba276d2367ec6/tree_sitter_language_pack-1.4.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:cfbe35783514c540894bb3880b58e2c6bd37d5b4f15d00a1ddf3d97ffe56c635", size = 2198889, upload-time = "2026-03-31T15:50:50.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/0a2546cff6beefd4ab89c8991b0492f3d284eef616827752e1d07c54bbe0/tree_sitter_language_pack-1.4.1-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:19d78b1897a6e9704dec56d78a2538749b401f160de481b739368ed4f086748a", size = 2375876, upload-time = "2026-03-31T15:50:53.197Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5e/5dc78a0c9a6ccf69ef3e971f98ad04202069f2e5c13f17160682ef2869a0/tree_sitter_language_pack-1.4.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c6e7da2ad0adffa40479c30a811fe27b1352d988b4fec1d69ac3c1970d48fe8e", size = 2513714, upload-time = "2026-03-31T15:50:55.142Z" }, + { url = "https://files.pythonhosted.org/packages/64/7a/6509dd2a577037586a2a2eeea76d5fd03e1f196785afbdab4cc5b2111439/tree_sitter_language_pack-1.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:e3c8f86ab8924be0913e8a0b529bde50d8a8d48774ce7fd94e485d79b4ab44bf", size = 2307738, upload-time = "2026-03-31T15:50:57.059Z" }, ] [[package]] @@ -6198,17 +6123,18 @@ wheels = [ [[package]] name = "tree-sitter-rust" -version = "0.24.0" +version = "0.24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/ae/fde1ab896f3d79205add86749f6f443537f59c747616a8fc004c7a453c29/tree_sitter_rust-0.24.0.tar.gz", hash = "sha256:c7185f482717bd41f24ffcd90b5ee24e7e0d6334fecce69f1579609994cd599d", size = 335850, upload-time = "2025-04-01T21:06:03.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/87/75cbd22b927267d310f76cca1ab3c1d9d41035dfa3eb9cc95f96ee199440/tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9", size = 339341, upload-time = "2026-03-27T21:08:55.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/29/0594a6b135d2475d1bb8478029dad127b87856eeb13b23ce55984dd22bb4/tree_sitter_rust-0.24.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ea455443f5ab245afd8c5ce63a8ae38da455ef27437b459ce3618a9d4ec4f9a", size = 131884, upload-time = "2025-04-01T21:05:56.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/00/4c400fe94eb3cb141b008b489d582dcd8b41e4168aca5dd8746c47a2b1bc/tree_sitter_rust-0.24.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:a0a1a2694117a0e86e156b28ee7def810ec94e52402069bf805be22d43e3c1a1", size = 137904, upload-time = "2025-04-01T21:05:57.743Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4d/c5eb85a68a2115d9f5c23fa5590a28873c4cf3b4e17c536ff0cb098e1a91/tree_sitter_rust-0.24.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3362992ea3150b0dd15577dd59caef4f2926b6e10806f2bb4f2533485acee2f", size = 166554, upload-time = "2025-04-01T21:05:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/ba/72/8ee8cf2bd51bc402531da7d8741838a4ea632b46a8c1e2df9968c7326cc7/tree_sitter_rust-0.24.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2c1f4b87df568352a9e523600af7cb32c5748dc75275f4794d6f811ab13dfe", size = 165457, upload-time = "2025-04-01T21:05:59.939Z" }, - { url = "https://files.pythonhosted.org/packages/74/d1/389eecb15c3f8ef4c947fcfbcc794ef4036b3b892c0f981e110860371daa/tree_sitter_rust-0.24.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:615f989241b717f14105b1bc621ff0c2200c86f1c3b36f1842d61f6605021152", size = 162857, upload-time = "2025-04-01T21:06:00.835Z" }, - { url = "https://files.pythonhosted.org/packages/b9/df/a6321043d6dee313e5fa3b6a13384119d590393368134cf12f2ee7f9e664/tree_sitter_rust-0.24.0-cp39-abi3-win_amd64.whl", hash = "sha256:2e29be0292eaf1f99389b3af4281f92187612af31ba129e90f4755f762993441", size = 130052, upload-time = "2025-04-01T21:06:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/c8/33/70b320d24cd127d6ca427d2bef1279830f0786a1f2cde160f59b4fb80728/tree_sitter_rust-0.24.0-cp39-abi3-win_arm64.whl", hash = "sha256:7a0538eaf4063b443c6cd80a47df19249f65e27dbdf129396a9193749912d0c0", size = 128583, upload-time = "2025-04-01T21:06:02.58Z" }, + { url = "https://files.pythonhosted.org/packages/d0/24/2b2d33af5e27c84a4fde4e8cd2594bb4ab1e1cf48756a9f40dadc84956cc/tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59", size = 129507, upload-time = "2026-03-27T21:08:47.585Z" }, + { url = "https://files.pythonhosted.org/packages/78/2a/cf39f881a545360b5a86bb1accba1f4acc713daab01fb9edd35b6e84f473/tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36", size = 136812, upload-time = "2026-03-27T21:08:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/ca/45/a051bbd3045a61182dde25b93ae9a33d2677c935b16952283e12eaf46051/tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190", size = 164706, upload-time = "2026-03-27T21:08:49.605Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/a5a146df5c0a5daea3ffcd5d7245775fe7f084357770d5a313dd6245ae78/tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a", size = 170310, upload-time = "2026-03-27T21:08:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/95/a8/f85b1ca75e01361ca5f92d226593ca4857cea49551b9f6c8fa6fc08ea917/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a", size = 168668, upload-time = "2026-03-27T21:08:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/3519f866a4679ca36acd9f5a06a779ecb8a92b18887c5546458d521df557/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89", size = 162403, upload-time = "2026-03-27T21:08:52.588Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/7ef609894dbfe5699eb16f7471f9b8af1d958d8ba3e29c238d7607e8cb47/tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d", size = 129422, upload-time = "2026-03-27T21:08:54.031Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, ] [[package]] @@ -6242,6 +6168,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/c7/dcf3ea1c4f5da9b10353b9af4455d756c92d728a8f58f03c480d3ef0ead5/tree_sitter_yaml-0.7.2-cp310-abi3-win_arm64.whl", hash = "sha256:f63c227b18e7ce7587bce124578f0bbf1f890ac63d3e3cd027417574273642c4", size = 44065, upload-time = "2025-10-07T14:40:35.337Z" }, ] +[[package]] +name = "ty" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" }, + { url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" }, +] + [[package]] name = "typeguard" version = "4.5.1" @@ -6271,32 +6221,32 @@ wheels = [ [[package]] name = "types-croniter" -version = "6.0.0.20250809" +version = "6.2.2.20260316" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/ac/7b26a9b19cc2b137293b14af71402ba83d13674c208b141474b6887465ae/types_croniter-6.0.0.20250809.tar.gz", hash = "sha256:c829295d4d65eaddcfafec905b0fbab59e72c3c91ee934a4d504dcafad79ff95", size = 11745, upload-time = "2025-08-09T03:14:10.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/2c/35806e52858cc442a613faeabcafc24dfa79d7c41b31efb2e03747f6f91b/types_croniter-6.2.2.20260316.tar.gz", hash = "sha256:ff569332e972f1f71c292ca6a10773ebf9f5ddd6d123595de9b6b03ccf89b9bd", size = 11958, upload-time = "2026-03-16T04:29:00.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/99/e8e40592fbecb6671b32e4dab4cca2a1d4fa7d5b2f54aece134ccb42e839/types_croniter-6.0.0.20250809-py3-none-any.whl", hash = "sha256:d9f53f3e837eb6af509e2090fd2f5bb29b38425dd78f77d7b3bf37ccd2b2bf93", size = 9712, upload-time = "2025-08-09T03:14:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/36/cc/ef3f526405c0752c8b755e302f6978f6e9d23b5f7fc89534e5a76f4c68da/types_croniter-6.2.2.20260316-py3-none-any.whl", hash = "sha256:9c0339d7615deabbc2ca9156048f621e417d4e43886d2b80de2d8a685914bd45", size = 9742, upload-time = "2026-03-16T04:28:59.944Z" }, ] [[package]] name = "types-docutils" -version = "0.22.3.20260223" +version = "0.22.3.20260322" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/33/92c0129283363e3b3ba270bf6a2b7d077d949d2f90afc4abaf6e73578563/types_docutils-0.22.3.20260223.tar.gz", hash = "sha256:e90e868da82df615ea2217cf36dff31f09660daa15fc0f956af53f89c1364501", size = 57230, upload-time = "2026-02-23T04:11:21.806Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bb/243a87fc1605a4a94c2c343d6dbddbf0d7ef7c0b9550f360b8cda8e82c39/types_docutils-0.22.3.20260322.tar.gz", hash = "sha256:e2450bb997283c3141ec5db3e436b91f0aa26efe35eb9165178ca976ccb4930b", size = 57311, upload-time = "2026-03-22T04:08:44.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/c7/a4ae6a75d5b07d63089d5c04d450a0de4a5d48ffcb84b95659b22d3885fe/types_docutils-0.22.3.20260223-py3-none-any.whl", hash = "sha256:cc2d6b7560a28e351903db0989091474aa619ad287843a018324baee9c4d9a8f", size = 91969, upload-time = "2026-02-23T04:11:20.966Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4a/22c090cd4615a16917dff817cbe7c5956da376c961e024c241cd962d2c3d/types_docutils-0.22.3.20260322-py3-none-any.whl", hash = "sha256:681d4510ce9b80a0c6a593f0f9843d81f8caa786db7b39ba04d9fd5480ac4442", size = 91978, upload-time = "2026-03-22T04:08:43.117Z" }, ] [[package]] name = "types-jsonschema" -version = "4.26.0.20260202" +version = "4.26.0.20260325" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/07/68f63e715eb327ed2f5292e29e8be99785db0f72c7664d2c63bd4dbdc29d/types_jsonschema-4.26.0.20260202.tar.gz", hash = "sha256:29831baa4308865a9aec547a61797a06fc152b0dac8dddd531e002f32265cb07", size = 16168, upload-time = "2026-02-02T04:11:22.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/bf/97b3438f0a3834d7d8e515fbccd4e1ca957465e094f0b260162a5cf9b951/types_jsonschema-4.26.0.20260325.tar.gz", hash = "sha256:84c319ba1af5463394d99accd96db543b7cb0eeab0938c652c18129536672002", size = 16441, upload-time = "2026-03-25T04:08:12.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/06/962d4f364f779d7389cd31a1bb581907b057f52f0ace2c119a8dd8409db6/types_jsonschema-4.26.0.20260202-py3-none-any.whl", hash = "sha256:41c95343abc4de9264e333a55e95dfb4d401e463856d0164eec9cb182e8746da", size = 15914, upload-time = "2026-02-02T04:11:21.61Z" }, + { url = "https://files.pythonhosted.org/packages/61/ec/65a4a55a024c9eb7fe08c207c0a94a537db0db50fea61ad565fa6b39220f/types_jsonschema-4.26.0.20260325-py3-none-any.whl", hash = "sha256:032a952fd32d9e06b71bdce5a5b4005dd58a074f6cb2899e96b633cbe1c28f40", size = 16080, upload-time = "2026-03-25T04:08:11.108Z" }, ] [[package]] @@ -6331,14 +6281,14 @@ wheels = [ [[package]] name = "types-requests" -version = "2.32.4.20260107" +version = "2.33.0.20260327" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/5f/2e3dbae6e21be6ae026563bad96cbf76602d73aa85ea09f13419ddbdabb4/types_requests-2.33.0.20260327.tar.gz", hash = "sha256:f4f74f0b44f059e3db420ff17bd1966e3587cdd34062fe38a23cda97868f8dd8", size = 23804, upload-time = "2026-03-27T04:23:38.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/951e733616c92cb96b57554746d2f65f4464d080cc2cc093605f897aba89/types_requests-2.33.0.20260327-py3-none-any.whl", hash = "sha256:fde0712be6d7c9a4d490042d6323115baf872d9a71a22900809d0432de15776e", size = 20737, upload-time = "2026-03-27T04:23:37.813Z" }, ] [[package]] @@ -6408,7 +6358,7 @@ wheels = [ [[package]] name = "upathtools" -version = "1.20.0" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofile" }, @@ -6417,9 +6367,9 @@ dependencies = [ { name = "ripgrep-rs" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/d8/188083987b643374ad4e38af8846dd0d5ef6db8ebbd663c36537a2d73969/upathtools-1.20.0.tar.gz", hash = "sha256:ff23d7996fc622339165f9756dd53892e67c265c02a7d5635c14648ed7809d8c", size = 203981, upload-time = "2026-02-23T18:33:01.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/77/0e03cd8f0420c002b289fe0568c6987cffb60cd0c8ad4da5a298882e84b3/upathtools-1.20.2.tar.gz", hash = "sha256:e0771f90c3f537a63586edaa2219907f7dc94f2cc70337207a6defd6a998f76e", size = 207245, upload-time = "2026-03-29T17:11:54.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/96/b8a0229bc391c7fb355cc314d2b457d5a505df14245e50cc4dd3cb9408a3/upathtools-1.20.0-py3-none-any.whl", hash = "sha256:fe26818fff83a839006545217c4f1c955bcfac2b3f833bc091876a3beea07b7e", size = 268681, upload-time = "2026-02-23T18:32:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2a/1bbd54f6a26bd0099b9a3e55004158117b0d1f5d8e5dd03a453213e28044/upathtools-1.20.2-py3-none-any.whl", hash = "sha256:e371597042aef751a3663fb4845b0632d23d976cfc4f2af39bcb973a8da0a680", size = 274175, upload-time = "2026-03-29T17:11:56.629Z" }, ] [package.optional-dependencies] @@ -6440,15 +6390,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.41.0" +version = "0.42.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [package.optional-dependencies] @@ -6586,22 +6536,38 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] @@ -6654,7 +6620,7 @@ wheels = [ [[package]] name = "xai-sdk" -version = "1.8.0" +version = "1.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -6666,9 +6632,9 @@ dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/752bf4a3e13619e6eb6c2ca0df18890aa70b21f299a51c126079e3c1c7b6/xai_sdk-1.8.0.tar.gz", hash = "sha256:614301eed7f7e986897ac8d6836900391756d0cdee725370d21e1a2c1b4b8bc3", size = 391420, upload-time = "2026-03-05T06:50:47.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/32/bb8385f7a3b05ce406b689aa000c9a34289caa1526f1c093a1cefc0d9695/xai_sdk-1.11.0.tar.gz", hash = "sha256:ca87a830d310fb8e06fba44fb2a8c5cdf0d9f716b61126eddd51b7f416a63932", size = 404313, upload-time = "2026-03-27T18:23:10.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/cd/183e7541990f46474ce33aaf2b2573f8848e71871669e01ed1fb02e6bc3b/xai_sdk-1.8.0-py3-none-any.whl", hash = "sha256:af63766db7f8a070d57e15e052fb57fdfacacbc604590c4350ad1aef03dda374", size = 242349, upload-time = "2026-03-05T06:50:45.789Z" }, + { url = "https://files.pythonhosted.org/packages/04/76/86d9a3589c725ce825d2ed3e7cb3ecf7f956d3fd015353d52197bb341bcd/xai_sdk-1.11.0-py3-none-any.whl", hash = "sha256:fe58ce6d8f8115ae8bd57ded57bcd847d0bb7cb28bb7b236abefd4626df1ed8d", size = 251388, upload-time = "2026-03-27T18:23:08.573Z" }, ] [[package]] @@ -6777,7 +6743,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.24" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -6787,20 +6753,20 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/96/9c6cbdd7b351d1023cdbbcf7872d4cb118b0334cfe5821b99e0dd18e3f00/zensical-0.0.24.tar.gz", hash = "sha256:b5d99e225329bf4f98c8022bdf0a0ee9588c2fada7b4df1b7b896fcc62b37ec3", size = 3840688, upload-time = "2026-02-26T09:43:44.557Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/aa/b8201af30e376a67566f044a1c56210edac5ae923fd986a836d2cf593c9c/zensical-0.0.24-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d390c5453a5541ca35d4f9e1796df942b6612c546e3153dd928236d3b758409a", size = 12263407, upload-time = "2026-02-26T09:43:14.716Z" }, - { url = "https://files.pythonhosted.org/packages/78/8e/3d910214471ade604fd39b080db3696864acc23678b5b4b8475c7dbfd2ce/zensical-0.0.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:81ac072869cf4d280853765b2bfb688653da0dfb9408f3ab15aca96455ab8223", size = 12142610, upload-time = "2026-02-26T09:43:17.546Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d7/eb0983640aa0419ddf670298cfbcf8b75629b6484925429b857851e00784/zensical-0.0.24-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5eb1dfa84cae8e960bfa2c6851d2bc8e9710c4c4c683bd3aaf23185f646ae46", size = 12508380, upload-time = "2026-02-26T09:43:20.114Z" }, - { url = "https://files.pythonhosted.org/packages/a3/04/4405b9e6f937a75db19f0d875798a7eb70817d6a3bec2a2d289a2d5e8aea/zensical-0.0.24-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57d7c9e589da99c1879a1c703e67c85eaa6be4661cdc6ce6534f7bb3575983f4", size = 12440807, upload-time = "2026-02-26T09:43:22.679Z" }, - { url = "https://files.pythonhosted.org/packages/12/dc/a7ca2a4224b3072a2c2998b6611ad7fd4f8f131ceae7aa23238d97d26e22/zensical-0.0.24-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42fcc121c3095734b078a95a0dae4d4924fb8fbf16bf730456146ad6cab48ad0", size = 12782727, upload-time = "2026-02-26T09:43:25.347Z" }, - { url = "https://files.pythonhosted.org/packages/42/37/22f1727da356ed3fcbd31f68d4a477f15c232997c87e270cfffb927459ac/zensical-0.0.24-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4a2a051b9f49561031a2986ace502326f82d9a401ddf125530d30025fdd4", size = 12547616, upload-time = "2026-02-26T09:43:28.031Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/c75ff111b8e12157901d00752beef9d691dbb5a034b6a77359972262416a/zensical-0.0.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5fea3bb61238dba9f930f52669db67b0c26be98e1c8386a05eb2b1e3cb875dc", size = 12684883, upload-time = "2026-02-26T09:43:30.642Z" }, - { url = "https://files.pythonhosted.org/packages/b9/92/4f6ea066382e3d068d3cadbed99e9a71af25e46c84a403e0f747960472a2/zensical-0.0.24-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:75eef0428eec2958590633fdc82dc2a58af124879e29573aa7e153b662978073", size = 12713825, upload-time = "2026-02-26T09:43:33.273Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fb/bf735b19bce0034b1f3b8e1c50b2896ebbd0c5d92d462777e759e78bb083/zensical-0.0.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c6b39659156394ff805b4831dac108c839483d9efa4c9b901eaa913efee1ac7", size = 12854318, upload-time = "2026-02-26T09:43:35.632Z" }, - { url = "https://files.pythonhosted.org/packages/7e/28/0ddab6c1237e3625e7763ff666806f31e5760bb36d18624135a6bb6e8643/zensical-0.0.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9eef82865a18b3ca4c3cd13e245dff09a865d1da3c861e2fc86eaa9253a90f02", size = 12818270, upload-time = "2026-02-26T09:43:37.749Z" }, - { url = "https://files.pythonhosted.org/packages/2a/93/d2cef3705d4434896feadffb5b3e44744ef9f1204bc41202c1b84a4eeef6/zensical-0.0.24-cp310-abi3-win32.whl", hash = "sha256:f4d0ff47d505c786a26c9332317aa3e9ad58d1382f55212a10dc5bafcca97864", size = 11857695, upload-time = "2026-02-26T09:43:39.906Z" }, - { url = "https://files.pythonhosted.org/packages/f1/26/9707587c0f6044dd1e1cc5bc3b9fa5fed81ce6c7bcdb09c21a9795e802d9/zensical-0.0.24-cp310-abi3-win_amd64.whl", hash = "sha256:e00a62cf04526dbed665e989b8f448eb976247f077a76dfdd84699ace4aa3ac3", size = 12057762, upload-time = "2026-02-26T09:43:42.627Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/db/cc4e555d2e816f2d91304ff969d62cc3a401ee477dbb7c720b874bec67d6/zensical-0.0.31-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b489936d670733dd204f16b689a2acc0e45b69e42cc4901f5131ae57658b8fbc", size = 12419980, upload-time = "2026-04-01T11:29:44.01Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c1/6789f73164c7f5821f5defb8a80b1dba8d5af24bdec7db36876793c5afd9/zensical-0.0.31-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9f678efc0d9918e45eeb8bc62847b2cce23db7393c8c59c1be6d1c064bbaacd", size = 12292301, upload-time = "2026-04-01T11:29:47.277Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9a/6a83ad209081a953e0285d5056e5452c4fbcabd2f104f3797d53e4bdd96f/zensical-0.0.31-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b50ecf674997f818e53f12f2a67875a21b0c79ed74c151dfaef2f1475e5bf", size = 12661472, upload-time = "2026-04-01T11:29:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4a/a82f5c81893b7a607cf9d439b75c3c3894b4ef4d3e92d5d818b4fa5c6f23/zensical-0.0.31-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6fb5c634fe88254770a2d4db5c05b06f1c3ee5e29d2ae3e7efdae8905e435b1d", size = 12603784, upload-time = "2026-04-01T11:29:53.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/1c/79c198628b8e006be32dfb1c5b73561757a349a6cf3069600a67ffa62495/zensical-0.0.31-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e64630552793274db1ec66c971e49a15ad351536d5d12de67ec6da7358ac50", size = 12959832, upload-time = "2026-04-01T11:29:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/db/9d/45839d9ca0f69622e8a3b944f2d8d7f7d2b7c2da78201079c4feb275feb6/zensical-0.0.31-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738a2fd5832e3b3c10ff642eebaf89c89ca1d28e4451dad0f36fdac53c415577", size = 12704024, upload-time = "2026-04-01T11:29:59.836Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/451d7f4d94092bc38bd8d514826fb7b0329c188db506795b1d20bd07d517/zensical-0.0.31-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bd601f6132e285ef6c3e4c3852be2094fc0473295a8080003db76a79760f84fb", size = 12837788, upload-time = "2026-04-01T11:30:03.048Z" }, + { url = "https://files.pythonhosted.org/packages/d8/39/390a8fc384fb174ebd4450343a0aa2877b3a31ddcedf5ef0b8d26944e12c/zensical-0.0.31-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc3b6a9dfb5903c0aa779ef65cd6185add2b8aa1db237be840874b8c9db761b8", size = 12876822, upload-time = "2026-04-01T11:30:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/d5/60/640da2f095782cf38974cd851fb7afa62651d09a36543a1d8942b31aabdc/zensical-0.0.31-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ddd4321b275e82c4897aa45b05038ce204b88fb311ad55f8c2af572173a9b56c", size = 13024036, upload-time = "2026-04-01T11:30:09.501Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/0564377cbfccea3653254adfa851c1b20d1696e4b16770c7b2e1dd1ef1d7/zensical-0.0.31-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:147ab4bc17f3088f703aa6c4b9c416411f4ea8ca64d26f6586beae49d97fd3c7", size = 12975505, upload-time = "2026-04-01T11:30:12.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/4b/b8a0c4e5937cb05882dcce667798403e00897135080a69f92363e5e3ff9f/zensical-0.0.31-cp310-abi3-win32.whl", hash = "sha256:03fa11e629a308507693489541f43e751697784e94365e7435b02104aefd1c2c", size = 12011233, upload-time = "2026-04-01T11:30:15.496Z" }, + { url = "https://files.pythonhosted.org/packages/3e/99/0eacdb466d344c0c86596932201268517be42f3e0bb6c78b2b0cd84c55f6/zensical-0.0.31-cp310-abi3-win_amd64.whl", hash = "sha256:d6621d4bb46af4143560045d4a18c8c76302db56bf1dbb6e2ce107d7fb643e09", size = 12207545, upload-time = "2026-04-01T11:30:19.054Z" }, ] [[package]]