From 8068549b2b9d780e990615318ca6fe532cc4d74d Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Wed, 24 Jun 2026 08:31:05 +0200 Subject: [PATCH 01/10] Implement planning service harness with agent loop, approval gate, context builder, reasoning controller, and tool registry - Added `AgentLoop` for managing multi-turn interactions and tool calls. - Introduced `ApprovalGate` to handle tool execution policies and user approvals. - Created `ContextBuilder` for generating prompts and managing conversation context. - Developed `ReasoningController` to extract and manage reasoning from model responses. - Established `ToolRegistry` to unify action tools and introspection tools with their policies. - Defined `ToolPolicy` and `ToolDescriptor` for managing tool behaviors and attributes. - Added comprehensive tests for harness components, ensuring functionality without a live LLM or FarmBot. --- .../planning_service/__init__.py | 379 +++------------ .../planning_service/agent.py | 33 +- .../planning_service/planning_service/chat.py | 446 +++--------------- .../planning_service/execution_tools.py | 144 ------ .../planning_service/harness/__init__.py | 29 ++ .../planning_service/harness/agent_loop.py | 322 +++++++++++++ .../planning_service/harness/approval_gate.py | 110 +++++ .../harness/context_builder.py | 223 +++++++++ .../harness/reasoning_controller.py | 86 ++++ .../planning_service/harness/tool_policy.py | 50 ++ .../planning_service/harness/tool_registry.py | 244 ++++++++++ .../planning_service/prompt.py | 186 +------- .../planning_service/tools.py | 111 +---- .../safety_service/safety_service/__init__.py | 47 +- tests/test_harness.py | 256 ++++++++++ tests/test_planning_prompts.py | 30 +- 16 files changed, 1571 insertions(+), 1125 deletions(-) delete mode 100644 services/planning_service/planning_service/execution_tools.py create mode 100644 services/planning_service/planning_service/harness/__init__.py create mode 100644 services/planning_service/planning_service/harness/agent_loop.py create mode 100644 services/planning_service/planning_service/harness/approval_gate.py create mode 100644 services/planning_service/planning_service/harness/context_builder.py create mode 100644 services/planning_service/planning_service/harness/reasoning_controller.py create mode 100644 services/planning_service/planning_service/harness/tool_policy.py create mode 100644 services/planning_service/planning_service/harness/tool_registry.py create mode 100644 tests/test_harness.py diff --git a/services/planning_service/planning_service/__init__.py b/services/planning_service/planning_service/__init__.py index 5cfc1b8..b7a3e00 100644 --- a/services/planning_service/planning_service/__init__.py +++ b/services/planning_service/planning_service/__init__.py @@ -2,44 +2,41 @@ Public surface: plan(request, *, world=None, registry=None, model=None) -> PlanResult + chat(...), stream_chat(...) -The default ``model`` is built from :func:`config.load_config` and uses -LangChain's ``ChatOpenAI`` against any OpenAI-compatible endpoint -(OpenRouter, llama.cpp, vLLM, Ollama, etc.). - -The model is bound to LangChain tools for every action kind (see -:mod:`tools`). Models that support tool-calling (most modern ones) will -produce structured ``tool_calls``; models that don't fall back to the -JSON path. Every plan still runs through ``safety_service.validate`` -before being returned. +The planner and chat interfaces are now driven by the same harness: +``ToolRegistry`` + ``ApprovalGate`` + ``AgentLoop``. The harness keeps +safety/approval policy in one place and lets the model reason across +multiple tool-call turns. """ from __future__ import annotations -import json import logging -import re from dataclasses import dataclass, field -from pathlib import Path from typing import Any from langchain_core.language_models import BaseChatModel -from pydantic import BaseModel, Field from twfarmbot_core.actions import ActionRegistry from twfarmbot_core.domain import Action -from spatial_service import format_world_context - -from .agent import build_base_model, build_tool_set +from .agent import build_base_model from .chat import ChatResult, chat, stream_chat from .config import PlannerConfig, load_config +from .harness import ( + AgentLoop, + ApprovalGate, + ContextBuilder, + ReasoningController, + ToolRegistry, +) from .introspection import ( InMemorySystemStateProvider, SystemStateProvider, build_introspection_tools, ) -from .parser import PlanError, parse_plan -from .prompt import build_chat_system_prompt, build_system_prompt, build_user_prompt +from .parser import PlanError, _extract_json, parse_plan +from .prompt import PlannerResponse from .tools import build_tools, extract_tool_calls, tool_calls_to_actions log = logging.getLogger(__name__) @@ -47,12 +44,7 @@ @dataclass(frozen=True) class PlanResult: - """The output of a single planning call. - - ``actions`` is empty when the model returned an empty plan (ambiguous - or refused request) — in that case ``rationale`` always explains - why. ``raw_text`` holds the model's exact output for debugging. - """ + """The output of a single planning call.""" request: str actions: list[Action] = field(default_factory=list) @@ -80,314 +72,96 @@ def plan( ) -> PlanResult: """Translate a natural-language ``request`` into a validated PlanResult. - Args: - request: Free-form task description, e.g. "water the tomato bed - for 90 seconds, then go home". - world: Optional world model (a ``GardenWorld`` or any object - with ``to_dict()``). When supplied, a compact summary is - included in the prompt so the model can ground names like - "tomato bed" to actual entities. - registry: Action vocabulary. Defaults to the api_server's - default registry. - model: Pre-built LangChain chat model. Defaults to one built - from ``config.load_config()``. - config: Optional planner config (overrides env-derived config). - system_state: Optional provider for live system state. When - supplied, the planner binds introspection tools - (``get_position``, ``list_zones``, …) so - the model can query the system during a planning call. - Pass ``InMemorySystemStateProvider(...)`` in tests. + The harness lets the model call introspection tools and collect action + proposals across multiple turns. Physical actions are never executed + inside ``plan()``; they are validated and returned for the caller to + execute or preview. """ cfg, base_model = build_base_model(model=model, config=config) registry = registry or get_default_registry() - world_context = format_world_context(world) if world is not None else None - user_msg = build_user_prompt( - request, - world_context=world_context, + tool_registry = ToolRegistry(registry, system_state) + approval_gate = ApprovalGate(registry, planning_mode=True) + context_builder = ContextBuilder(tool_registry, world=world) + planner_model = base_model.bind_tools(tool_registry.langchain_tools()) + + loop = AgentLoop( + model=planner_model, + tool_registry=tool_registry, + approval_gate=approval_gate, + context_builder=context_builder, + reasoning=ReasoningController(), + propose_only=False, + allow_actions=False, + max_iterations=3, + include_reasoning=False, ) - # Bind all tools: action tools + (optional) introspection tools. - all_tools = build_tool_set( - registry, system_state, for_chat=False, propose_only=False, allow_actions=True - ) - chat_model = base_model.bind_tools(all_tools) if all_tools else base_model - - # Preferred path: structured output via Pydantic. Forces the model - # to fill the PlannerResponse fields directly — eliminates the - # "I'll just chat instead" failure mode. - structured = _try_structured_output(base_model, all_tools) - if structured is not None: - messages = [ - {"role": "system", "content": build_system_prompt(registry.kinds())}, - {"role": "user", "content": user_msg}, - ] - log.info( - "planning via with_structured_output (%s/%s, tools=%d)", - cfg.base_url, cfg.model, len(all_tools), - ) - try: - parsed = structured.invoke(messages) - except Exception as err: # noqa: BLE001 — fall through to legacy path - log.warning("structured-output path failed (%s); falling back", err) - parsed = None - if parsed is not None: - actions = _actions_from_structured(parsed, registry) - rationale = getattr(parsed, "rationale", "") or "Plan built from structured output." - raw_text = json.dumps(parsed.model_dump() if hasattr(parsed, "model_dump") else parsed.__dict__) - return PlanResult( - request=request, - actions=actions, - rationale=rationale, - raw_text=raw_text, - ) + log.info("planning request via %s/%s", cfg.base_url, cfg.model) + result = loop.plan_request(request) - messages: list[tuple[str, str]] = [ - ("system", build_system_prompt(registry.kinds())), - ("user", user_msg), - ] + actions = _extract_actions(result.tool_calls, registry, approval_gate) + raw_text = result.response + rationale = result.response.strip() - log.info( - "planning request via %s/%s (tools=%d)", cfg.base_url, cfg.model, len(all_tools), - ) - response = chat_model.invoke(messages) - text = _text_from_response(response) - - # Preferred path: tool calls from the bound model. - tool_calls = extract_tool_calls(response) - if tool_calls: + if not actions and raw_text: + # Fallback: non-tool-calling models may return free-form JSON. try: - actions, introspect_results = _actions_from_tool_calls_with_introspection( - tool_calls, registry, system_state, - ) - except (PlanError, ValueError) as err: - log.warning("tool-calling path failed (%s); falling back to JSON", err) + actions, rationale = _parse_with_rationale(raw_text, registry) + except PlanError as err: + log.warning("JSON fallback failed (%s); returning empty plan", err) actions = [] - introspect_results = [] - if actions: - rationale = _extract_rationale_from_text(text) - log.info("planner produced %d action(s) via tool calls", len(actions)) - return PlanResult( - request=request, - actions=actions, - rationale=rationale or "Plan built from tool calls.", - raw_text=text, - ) + rationale = raw_text.strip() or "planner could not produce a plan" + + if not rationale: + rationale = "Plan built from tool calls." - # Fallback path: free-form JSON in the model's text. - try: - actions, rationale = _parse_with_rationale(text, registry) - except PlanError as err: - log.warning("JSON fallback failed (%s); returning empty plan", err) - actions = [] - rationale = text.strip() or "planner could not produce a JSON plan" - log.info("planner produced %d action(s) via JSON fallback", len(actions)) return PlanResult( request=request, actions=actions, rationale=rationale, - raw_text=text, + raw_text=raw_text, ) -def _try_structured_output( - base_model: BaseChatModel, tools: list[BaseTool] -) -> Any | None: - """Wrap the model with ``with_structured_output(PlannerResponse)``. - - Returns ``None`` if the model doesn't support it, so the caller can - fall back to the tool-calling / JSON paths. - """ - try: - return base_model.with_structured_output(_PlannerResponseModel) - except (NotImplementedError, AttributeError, TypeError, ValueError): - return None - - -class _PlannerActionModel(BaseModel): - kind: str - params: dict[str, Any] = Field(default_factory=dict) - - -class _PlannerResponseModel(BaseModel): - actions: list[_PlannerActionModel] = Field(default_factory=list) - rationale: str = "" - - -def _actions_from_structured( - parsed: Any, registry: ActionRegistry -) -> list[Action]: - from safety_service import validate as safety_validate - - known = set(registry.kinds()) - actions: list[Action] = [] - raw_actions = getattr(parsed, "actions", []) or [] - for item in raw_actions: - kind = getattr(item, "kind", None) or (item.get("kind") if isinstance(item, dict) else None) - params = getattr(item, "params", None) or (item.get("params", {}) if isinstance(item, dict) else {}) - if kind not in known: - raise PlanError(f"structured output has unknown kind {kind!r}") - action = Action(kind=kind, params=dict(params or {})) - safety_validate(action) - actions.append(action) - return actions - - -def _actions_from_tool_calls( +def _extract_actions( tool_calls: list[dict[str, Any]], registry: ActionRegistry, + approval_gate: ApprovalGate, ) -> list[Action]: - """Convert LangChain tool_calls into safety-validated ``Action``s. - - Only action tools (move, water, …) are returned. Introspection tool - calls are dropped here — they're handled by - :func:`_actions_from_tool_calls_with_introspection` when a system - state provider is supplied. - """ - from safety_service import validate as safety_validate - + """Turn action tool calls from the planning loop into validated Actions.""" known = set(registry.kinds()) - pairs = tool_calls_to_actions(tool_calls) actions: list[Action] = [] - for kind, params in pairs: - if kind not in known: - raise PlanError(f"tool produced unknown action kind {kind!r}") - action = Action(kind=kind, params=params) - safety_validate(action) - actions.append(action) - return actions - - -def _actions_from_tool_calls_with_introspection( - tool_calls: list[dict[str, Any]], - registry: ActionRegistry, - system_state: SystemStateProvider | None, -) -> tuple[list[Action], list[dict[str, Any]]]: - """Resolve mixed tool calls: run introspection tools, keep action tools. - - Returns ``(actions, introspection_results)`` so the caller can log - what the model looked at. - """ - action_pairs: list[tuple[str, dict[str, Any]]] = [] - introspect_calls: list[dict[str, Any]] = [] for call in tool_calls: - name = call["name"] - if name in { - "move", "water", "find_home", "read_pin", "write_pin", - "take_photo", "send_message", "mount_tool", - "dismount_tool", "e_stop", - }: - action_pairs.append((name, call.get("args", {}))) - else: - introspect_calls.append(call) - - introspect_results: list[dict[str, Any]] = [] - if system_state is not None and introspect_calls: - tools = {t.name: t for t in build_introspection_tools(system_state)} - for call in introspect_calls: - tool = tools.get(call["name"]) - if tool is None: - introspect_results.append( - {"tool": call["name"], "error": "unknown introspection tool"} - ) - continue - try: - result = tool.invoke(call.get("args", {})) - except Exception as err: # noqa: BLE001 - result = {"error": f"{type(err).__name__}: {err}"} - introspect_results.append({"tool": call["name"], "result": result}) - - actions = _actions_from_tool_calls( - [{"name": k, "args": p} for k, p in action_pairs], registry - ) - return actions, introspect_results + name = call.get("name") + if name not in known: + continue + result = call.get("result", {}) + params = ( + result.get("params", call.get("args", {})) + if isinstance(result, dict) + else call.get("args", {}) + ) + approval_gate.check_safety(name, params) + actions.append(Action(kind=name, params=dict(params))) + return actions -def _parse_with_rationale(text: str, registry: ActionRegistry) -> tuple[list[Action], str]: +def _parse_with_rationale( + text: str, registry: ActionRegistry +) -> tuple[list[Action], str]: """Parse the LLM output, returning (actions, rationale).""" - rationale = _extract_rationale_from_text(text) actions = parse_plan(text, registry) + rationale = "" + try: + raw = _extract_json(text) + response = PlannerResponse.model_validate(raw) + rationale = response.rationale + except Exception: # noqa: BLE001 + pass return actions, rationale -def _extract_rationale_from_text(text: str) -> str: - for match in _JSON_FENCE_RE.finditer(text): - try: - obj = json.loads(match.group(1)) - except json.JSONDecodeError: - continue - if isinstance(obj, dict) and isinstance(obj.get("rationale"), str): - return obj["rationale"] - start = text.find("{") - if start == -1: - return "" - depth = 0 - for end in range(start, len(text)): - if text[end] == "{": - depth += 1 - elif text[end] == "}": - depth -= 1 - if depth == 0: - try: - obj = json.loads(text[start : end + 1]) - except json.JSONDecodeError: - return "" - if isinstance(obj, dict) and isinstance(obj.get("rationale"), str): - return obj["rationale"] - return "" - return "" - - -_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) - - - -_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) - - -def _world_context(world: Any) -> str: - """Render a compact, model-friendly summary of the world model. - - Mirrors the YAML: name, id, kind, bounds, entity positions. Nothing - derived, nothing invented — the model does the arithmetic if it needs - a center. - """ - snapshot = world.to_dict() if hasattr(world, "to_dict") else dict(world) - lines: list[str] = [] - for zone in snapshot.get("zones", []): - bounds = zone.get("bounds", {}) - x = bounds.get("x", 0) - y = bounds.get("y", 0) - w = bounds.get("width", 0) - h = bounds.get("height", 0) - name = zone.get("name", zone.get("id")) - lines.append( - f"- zone {name!r} " - f"(kind={zone.get('kind')}, id={zone.get('id')}, " - f"x={x}, y={y}, width={w}, height={h})" - ) - for entity in snapshot.get("entities", []): - pos = entity.get("position", {}) - lines.append( - f"- entity {entity.get('name', entity.get('id'))!r} " - f"(kind={entity.get('kind')}, id={entity.get('id')}, " - f"x={pos.get('x')}, y={pos.get('y')}, z={pos.get('z')})" - ) - return "\n".join(lines) if lines else "(no zones or entities configured)" - - -def _text_from_response(response: Any) -> str: - content = getattr(response, "content", response) - if isinstance(content, str): - return content - if isinstance(content, list): - return "".join( - block.get("text", "") if isinstance(block, dict) else str(block) - for block in content - ) - return str(content) - - __all__ = [ "Action", "ChatResult", @@ -396,8 +170,7 @@ def _text_from_response(response: Any) -> str: "PlanResult", "PlannerConfig", "SystemStateProvider", - "build_chat_model", - "build_chat_system_prompt", + "build_base_model", "build_introspection_tools", "build_tools", "chat", diff --git a/services/planning_service/planning_service/agent.py b/services/planning_service/planning_service/agent.py index b0a9eff..436fe7e 100644 --- a/services/planning_service/planning_service/agent.py +++ b/services/planning_service/planning_service/agent.py @@ -10,9 +10,8 @@ from .client import build_chat_model from .config import PlannerConfig, load_config -from .execution_tools import build_execution_tools -from .introspection import SystemStateProvider, build_introspection_tools -from .tools import build_tools +from .harness import ToolRegistry +from .introspection import SystemStateProvider def build_base_model( @@ -42,22 +41,14 @@ def build_tool_set( ) -> list[BaseTool]: """Build the combined tool list for a chat/planner run. - Execution tools win name collisions against introspection tools (e.g. - ``read_pin``) so the robot actually changes state. + The harness owns approval/execution semantics; this helper now just + returns schema-complete LangChain tools generated from the unified + ``ToolRegistry``. + + The ``for_chat``, ``propose_only``, and ``allow_actions`` parameters + are kept for backward compatibility but no longer change the returned + tool schemas — policy is applied at invocation time by ``AgentLoop``. """ - if for_chat: - execution_tools = ( - build_execution_tools(registry, propose_only=propose_only) - if allow_actions - else [] - ) - else: - execution_tools = build_tools(registry) - - introspection_tools = ( - build_introspection_tools(system_state) if system_state is not None else [] - ) - execution_names = {t.name for t in execution_tools} - return list(execution_tools) + [ - t for t in introspection_tools if t.name not in execution_names - ] + del for_chat, propose_only, allow_actions + tool_registry = ToolRegistry(registry, system_state) + return tool_registry.langchain_tools() diff --git a/services/planning_service/planning_service/chat.py b/services/planning_service/planning_service/chat.py index bc69980..ff1b196 100644 --- a/services/planning_service/planning_service/chat.py +++ b/services/planning_service/planning_service/chat.py @@ -1,30 +1,27 @@ """Conversational chat interface for the FarmBot. -The model is bound to both introspection (read-only) and execution tools so it -can answer "what is the status?" and also water, take photos, move, etc. +The heavy lifting is delegated to the harness ``AgentLoop``; this module +just wires it to the public ``chat()`` / ``stream_chat()`` signatures. """ from __future__ import annotations -import json -import logging -import re from dataclasses import dataclass, field -from typing import Any - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import BaseTool +from typing import Any, Iterator +from langchain_core.language_models import BaseChatModel from twfarmbot_core.actions import ActionRegistry -from spatial_service import format_world_context - -from .agent import build_base_model, build_tool_set -from .config import PlannerConfig, load_config +from .agent import build_base_model +from .config import PlannerConfig +from .harness import ( + AgentLoop, + ApprovalGate, + ContextBuilder, + ReasoningController, + ToolRegistry, +) from .introspection import SystemStateProvider -from .prompt import build_chat_system_prompt - -log = logging.getLogger(__name__) @dataclass(frozen=True) @@ -38,62 +35,40 @@ class ChatResult: thinking: str | None = None -def _to_langchain_message( - message: dict[str, Any], *, include_reasoning: bool = False -) -> SystemMessage | HumanMessage | AIMessage | None: - role = message.get("role", "") - content = message.get("content", "") - if role == "user": - return HumanMessage(content=str(content)) - if role == "assistant": - kwargs: dict[str, Any] = {} - if include_reasoning: - thinking = message.get("thinking") - if thinking: - kwargs["additional_kwargs"] = {"reasoning_content": str(thinking)} - return AIMessage(content=str(content), **kwargs) - # Unknown / UI-only roles are not sent back to the model. - return None - +def _include_reasoning(cfg: PlannerConfig) -> bool: + return "deepseek" in cfg.model.lower() and "v4" in cfg.model.lower() -THINK_TAG_RE = re.compile(r"(.*?)", re.DOTALL) - - -def _extract_thinking(message: Any) -> str | None: - """Extract reasoning / thinking content from a LangChain message. - - Tries, in order: - 1. ``...`` tags in the message content. - 2. Provider-specific metadata fields such as ``reasoning_content`` - (DeepSeek / OpenRouter) or ``thinking`` (Claude). - """ - content = str(getattr(message, "content", "") or "") - match = THINK_TAG_RE.search(content) - if match: - thinking = match.group(1).strip() - return thinking if thinking else None - for key in ("reasoning_content", "thinking", "reasoning"): - value = getattr(message, "response_metadata", {}).get(key) or getattr( - message, "additional_kwargs", {} - ).get(key) - if value: - return str(value).strip() or None - return None - - -def _llm_friendly_result(result: Any) -> Any: - """Return a version of a tool result suitable for the LLM context. - - Large binary payloads (e.g. base64 analysis images) are replaced with a - placeholder so they do not waste tokens or overflow the context window. - The full result is still returned to the UI via tool_call events. - """ - if isinstance(result, dict) and "image_url" in result: - out = dict(result) - out["image_url"] = "[image data shown to user in chat]" - return out - return result +def _make_loop( + messages: list[dict[str, Any]], + *, + registry: ActionRegistry, + world: Any = None, + system_state: SystemStateProvider | None = None, + model: BaseChatModel | None = None, + config: PlannerConfig | None = None, + allow_actions: bool = True, + propose_only: bool = False, + max_iterations: int = 5, +) -> AgentLoop: + cfg, base_model = build_base_model(model=model, config=config) + tool_registry = ToolRegistry(registry, system_state) + approval_gate = ApprovalGate(registry) + context_builder = ContextBuilder( + tool_registry, world=world, propose_only=propose_only + ) + chat_model = base_model.bind_tools(tool_registry.langchain_tools()) + return AgentLoop( + model=chat_model, + tool_registry=tool_registry, + approval_gate=approval_gate, + context_builder=context_builder, + reasoning=ReasoningController(), + propose_only=propose_only, + allow_actions=allow_actions, + max_iterations=max_iterations, + include_reasoning=_include_reasoning(cfg), + ) def chat( @@ -102,7 +77,7 @@ def chat( registry: ActionRegistry, world: Any = None, system_state: SystemStateProvider | None = None, - model: Any = None, + model: BaseChatModel | None = None, config: PlannerConfig | None = None, allow_actions: bool = True, propose_only: bool = False, @@ -114,99 +89,27 @@ def chat( turns, no system message). The function prepends a system prompt, runs the model, executes any tool calls, and returns the final assistant text plus a log of tool calls made. - - When ``propose_only=True`` action tools do not mutate the robot. They - only record proposed actions so the UI can ask the user for approval. """ - cfg, base_model = build_base_model(model=model, config=config) - all_tools = build_tool_set( - registry, - system_state, - for_chat=True, - propose_only=propose_only, + loop = _make_loop( + messages, + registry=registry, + world=world, + system_state=system_state, + model=model, + config=config, allow_actions=allow_actions, + propose_only=propose_only, + max_iterations=max_iterations, ) - chat_model = base_model.bind_tools(all_tools) if all_tools else base_model - tool_map = {t.name: t for t in all_tools} - - system_prompt = build_chat_system_prompt( - registry.kinds(), propose_only=propose_only - ) - if world is not None: - world_context = format_world_context(world) - if world_context: - system_prompt += "\n\nCurrent world model:\n" + world_context - - include_reasoning = "deepseek" in cfg.model.lower() and "v4" in cfg.model.lower() - langchain_messages = [SystemMessage(content=system_prompt)] - for msg in messages: - lc_msg = _to_langchain_message(msg, include_reasoning=include_reasoning) - if lc_msg is not None: - langchain_messages.append(lc_msg) - - tool_log: list[dict[str, Any]] = [] - proposed_actions: list[dict[str, Any]] = [] - final_response = "" - final_thinking: str | None = None - last_response: Any = None - - for _ in range(max_iterations): - response = chat_model.invoke(langchain_messages) - last_response = response - tool_calls = getattr(response, "tool_calls", None) or [] - if not tool_calls: - final_response = str(response.content or "") - final_thinking = _extract_thinking(response) - break - - langchain_messages.append(response) - for call in tool_calls: - name = call.get("name") - args = call.get("args", {}) - tool_call_id = call.get("id", "") - tool = tool_map.get(name) - if tool is None: - result = {"error": f"unknown tool {name!r}"} - else: - try: - result = tool.invoke(args) - except Exception as err: # noqa: BLE001 - result = {"error": f"{type(err).__name__}: {err}"} - tool_log.append({"name": name, "args": args, "result": result}) - if isinstance(result, dict) and result.get("status") == "proposed": - proposed_actions.append( - { - "kind": result.get("kind", name), - "params": result.get("params", args), - } - ) - langchain_messages.append( - ToolMessage( - content=json.dumps(_llm_friendly_result(result)), - tool_call_id=tool_call_id, - name=name, - ) - ) - else: - # Hit the iteration limit; return the last model text if any. - final_response = str(getattr(last_response, "content", "") or "") - final_thinking = _extract_thinking(last_response) - if not final_response: - final_response = ( - "I ran too many tool calls without finishing. Please try again." - ) - - # Strip tags from the visible response so they don't render twice. - final_response = THINK_TAG_RE.sub("", final_response).strip() - + result = loop.run(messages) out_messages = list(messages) - out_messages.append({"role": "assistant", "content": final_response}) + out_messages.append({"role": "assistant", "content": result.response}) return ChatResult( - response=final_response, - proposed_actions=proposed_actions, - tool_calls=tool_log, + response=result.response, + proposed_actions=result.proposed_actions, + tool_calls=result.tool_calls, messages=out_messages, - thinking=final_thinking, + thinking=result.thinking, ) @@ -216,222 +119,29 @@ def stream_chat( registry: ActionRegistry, world: Any = None, system_state: SystemStateProvider | None = None, - model: Any = None, + model: BaseChatModel | None = None, config: PlannerConfig | None = None, allow_actions: bool = True, propose_only: bool = False, max_iterations: int = 5, -): - """Streaming version of :func:`chat`. +) -> Iterator[dict[str, Any]]: + """Streaming conversational assistant. Yields events: - - ``{"type": "delta", "content": "..."}`` for each piece of the - final assistant text. - - ``{"type": "tool_call", "name": ..., "args": ..., "result": ...}`` - after a tool is executed. - - ``{"type": "meta", "tool_calls": [...], "proposed_actions": [...]}`` - at the very end. - - Tool calls are resolved server-side; the text stream only starts after - the model has finished using read-only tools and decided on a final - answer. + - ``{"type": "tool_call", ...}`` after a tool is executed. + - ``{"type": "meta", "tool_calls": [...], "proposed_actions": [...]}``. + - ``{"type": "thinking", "content": "..."}`` for reasoning traces. + - ``{"type": "delta", "content": "..."}`` for the final answer text. """ - cfg, base_model = build_base_model(model=model, config=config) - all_tools = build_tool_set( - registry, - system_state, - for_chat=True, - propose_only=propose_only, + loop = _make_loop( + messages, + registry=registry, + world=world, + system_state=system_state, + model=model, + config=config, allow_actions=allow_actions, + propose_only=propose_only, + max_iterations=max_iterations, ) - chat_model = base_model.bind_tools(all_tools) if all_tools else base_model - tool_map = {t.name: t for t in all_tools} - - system_prompt = build_chat_system_prompt( - registry.kinds(), propose_only=propose_only - ) - if world is not None: - world_context = format_world_context(world) - if world_context: - system_prompt += "\n\nCurrent world model:\n" + world_context - - include_reasoning = "deepseek" in cfg.model.lower() and "v4" in cfg.model.lower() - langchain_messages = [SystemMessage(content=system_prompt)] - for msg in messages: - lc_msg = _to_langchain_message(msg, include_reasoning=include_reasoning) - if lc_msg is not None: - langchain_messages.append(lc_msg) - - tool_log: list[dict[str, Any]] = [] - proposed_actions: list[dict[str, Any]] = [] - action_tool_names = {t.name for t in all_tools if t.name in registry.kinds()} - last_response = None - - for _ in range(max_iterations): - response = chat_model.invoke(langchain_messages) - last_response = response - tool_calls = getattr(response, "tool_calls", None) or [] - if not tool_calls: - break - langchain_messages.append(response) - for call in tool_calls: - name = call.get("name") - args = call.get("args", {}) - tool_call_id = call.get("id", "") - tool = tool_map.get(name) - if tool is None: - result = {"error": f"unknown tool {name!r}"} - else: - try: - result = tool.invoke(args) - except Exception as err: # noqa: BLE001 - result = {"error": f"{type(err).__name__}: {err}"} - tool_log.append({"name": name, "args": args, "result": result}) - if isinstance(result, dict) and result.get("status") == "proposed": - proposed_actions.append( - { - "kind": result.get("kind", name), - "params": result.get("params", args), - } - ) - yield {"type": "tool_call", "name": name, "args": args, "result": result} - langchain_messages.append( - ToolMessage( - content=json.dumps(_llm_friendly_result(result)), - tool_call_id=tool_call_id, - name=name, - ) - ) - - # Fallback: some models describe the proposed action in text without ever - # calling the action tool. If no action tool was invoked but the answer - # looks like a proposal, ask the planner for a concrete action list so the - # UI can render Approve/Reject buttons. - if ( - not any(tc["name"] in action_tool_names for tc in tool_log) - and last_response is not None - and _response_describes_action(str(last_response.content or ""), messages) - ): - from planning_service import plan as planner_plan - - last_user = next( - ( - str(m.get("content", "")) - for m in reversed(messages) - if m.get("role") == "user" - ), - "", - ) - try: - plan_result = planner_plan( - last_user, - registry=registry, - world=world, - system_state=system_state, - model=base_model, - config=cfg, - ) - except Exception: # noqa: BLE001 - plan_result = None - if plan_result and plan_result.actions: - proposed_actions = [ - {"kind": a.kind, "params": a.params} for a in plan_result.actions - ] - tool_log.append( - { - "name": "planner_fallback", - "args": {"request": last_user}, - "result": { - "status": "proposed", - "actions": proposed_actions, - "rationale": plan_result.rationale, - }, - } - ) - - yield {"type": "meta", "tool_calls": tool_log, "proposed_actions": proposed_actions} - - # If the model exposed thinking/reasoning on the last tool-call turn, - # surface it before the answer text stream starts. - tool_turn_thinking = ( - _extract_thinking(last_response) if last_response is not None else None - ) - if tool_turn_thinking: - yield {"type": "thinking", "content": tool_turn_thinking} - - buffer = "" - streamed_reasoning: list[str] = [] - streamed_reasoning_emitted = bool(tool_turn_thinking) - for chunk in chat_model.stream(langchain_messages): - reasoning = getattr(chunk, "additional_kwargs", {}).get("reasoning") - if reasoning: - streamed_reasoning.append(str(reasoning)) - content = getattr(chunk, "content", None) - if content and streamed_reasoning and not streamed_reasoning_emitted: - yield {"type": "thinking", "content": "".join(streamed_reasoning)} - streamed_reasoning_emitted = True - if not content: - continue - buffer += str(content) - - # Extract complete ... blocks and keep the rest. - while True: - start = buffer.find("") - end = buffer.find("") - if start != -1 and end != -1 and end > start: - prefix = buffer[:start] - think = buffer[start + 7 : end] - suffix = buffer[end + 8 :] - if prefix: - yield {"type": "delta", "content": prefix} - if think: - yield {"type": "thinking", "content": think} - buffer = suffix - continue - break - - # No open think tag: emit what we have and reset. - if "" not in buffer: - if buffer: - yield {"type": "delta", "content": buffer} - buffer = "" - - if buffer: - yield {"type": "delta", "content": buffer} - - -def _response_describes_action( - response_text: str, messages: list[dict[str, Any]] -) -> bool: - """Detect when the model described an action but skipped the tool call.""" - text = response_text.lower() - last_user = next( - ( - str(m.get("content", "")).lower() - for m in reversed(messages) - if m.get("role") == "user" - ), - "", - ) - action_words = { - "move", - "water", - "irrigate", - "find_home", - "home", - "e_stop", - "mount_tool", - "dismount_tool", - } - if not any(word in last_user or word in text for word in action_words): - return False - # Concrete proposal indicators - if re.search(r"→|->", text): - return True - if re.search( - r"\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)", text - ): - return True - if ("seconds" in text) and ("water" in text or "irrigate" in text): - return True - return False + yield from loop.stream(messages) diff --git a/services/planning_service/planning_service/execution_tools.py b/services/planning_service/planning_service/execution_tools.py deleted file mode 100644 index 6b346b3..0000000 --- a/services/planning_service/planning_service/execution_tools.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Real execution tools for the chatbot. - -Unlike the schema-only action tools in :mod:`tools`, these actually call -``ActionRegistry.dispatch`` so the LLM can water, move, take photos, etc., -during a conversation. Every dispatch runs through the safety validator. -""" - -from __future__ import annotations - -from typing import Any - -from langchain_core.tools import BaseTool, tool - -from twfarmbot_core.actions import ActionRegistry -from twfarmbot_core.domain import Action - -from .tools import ( - FindHomeArgs, - MountToolArgs, - MoveArgs, - ReadPinArgs, - SendMessageArgs, - WaterArgs, - WritePinArgs, -) - - -def build_execution_tools( - registry: ActionRegistry, *, propose_only: bool = False -) -> list[BaseTool]: - """Build LangChain tools that either execute or propose actions. - - When ``propose_only=True`` the tools do **not** mutate the robot. They - return a proposed-action marker so the caller can ask the user for - approval before executing. - """ - - # Tools that physically move the robot or change outputs require explicit - # user approval even when the chat is in "action" mode. Safe / read-only - # actions execute immediately. - APPROVAL_TOOLS = {"move", "water", "find_home", "write_pin", "mount_tool", "dismount_tool"} - - def _dispatch_or_propose( - kind: str, - params: dict[str, Any], - *, - requires_approval: bool, - ) -> dict[str, Any]: - if propose_only and requires_approval: - return { - "status": "proposed", - "kind": kind, - "params": params, - "note": "This action is proposed and requires user approval.", - } - try: - result = registry.dispatch(Action(kind=kind, params=params)) - return {"status": "ok", "kind": kind, "params": result.params} - except Exception as err: # noqa: BLE001 - return {"status": "error", "kind": kind, "error": f"{type(err).__name__}: {err}"} - - tools: list[BaseTool] = [] - - if "move" in registry.kinds(): - @tool(args_schema=MoveArgs) - def move(x: float, y: float, z: float) -> dict[str, Any]: - """Move the gantry to absolute coordinates (mm).""" - return _dispatch_or_propose("move", {"x": x, "y": y, "z": z}, requires_approval=True) - tools.append(move) - - if "water" in registry.kinds(): - @tool(args_schema=WaterArgs) - def water(seconds: float) -> dict[str, Any]: - """Turn the pump on for the given seconds.""" - return _dispatch_or_propose("water", {"seconds": seconds}, requires_approval=True) - tools.append(water) - - if "find_home" in registry.kinds(): - @tool(args_schema=FindHomeArgs) - def find_home(axis: str = "all", speed: int = 100) -> dict[str, Any]: - """Run the end-stop homing sequence to calibrate axes.""" - return _dispatch_or_propose( - "find_home", {"axis": axis, "speed": speed}, requires_approval=True - ) - tools.append(find_home) - - if "read_pin" in registry.kinds(): - @tool(args_schema=ReadPinArgs) - def read_pin(pin: int, mode: str = "digital") -> dict[str, Any]: - """Read a GPIO pin value.""" - return _dispatch_or_propose("read_pin", {"pin": pin, "mode": mode}, requires_approval=False) - tools.append(read_pin) - - if "write_pin" in registry.kinds(): - @tool(args_schema=WritePinArgs) - def write_pin(pin: int, value: int, mode: str = "digital") -> dict[str, Any]: - """Write a GPIO pin to the given value (0 or 1).""" - return _dispatch_or_propose( - "write_pin", {"pin": pin, "value": value, "mode": mode}, requires_approval=True - ) - tools.append(write_pin) - - if "take_photo" in registry.kinds(): - @tool - def take_photo() -> dict[str, Any]: - """Trigger the camera to take a photo.""" - return _dispatch_or_propose("take_photo", {}, requires_approval=False) - tools.append(take_photo) - - if "send_message" in registry.kinds(): - @tool(args_schema=SendMessageArgs) - def send_message(message: str, message_type: str = "info") -> dict[str, Any]: - """Show a message to the user.""" - return _dispatch_or_propose( - "send_message", - {"message": message, "message_type": message_type}, - requires_approval=False, - ) - tools.append(send_message) - - if "mount_tool" in registry.kinds(): - @tool(args_schema=MountToolArgs) - def mount_tool(tool_name: str) -> dict[str, Any]: - """Mount a named tool on the gantry.""" - return _dispatch_or_propose( - "mount_tool", {"tool_name": tool_name}, requires_approval=True - ) - tools.append(mount_tool) - - if "dismount_tool" in registry.kinds(): - @tool - def dismount_tool() -> dict[str, Any]: - """Dismount whatever tool is currently mounted.""" - return _dispatch_or_propose("dismount_tool", {}, requires_approval=True) - tools.append(dismount_tool) - - if "e_stop" in registry.kinds(): - @tool - def e_stop() -> dict[str, Any]: - """Emergency stop — halt the robot immediately.""" - return _dispatch_or_propose("e_stop", {}, requires_approval=False) - tools.append(e_stop) - - return tools diff --git a/services/planning_service/planning_service/harness/__init__.py b/services/planning_service/planning_service/harness/__init__.py new file mode 100644 index 0000000..c19b2d8 --- /dev/null +++ b/services/planning_service/planning_service/harness/__init__.py @@ -0,0 +1,29 @@ +"""FarmBot LLM harness primitives. + +A small, reusable layer on top of LangChain that owns the agent loop, +tool policy, approval gate, reasoning extraction, and prompt context. +""" + +from __future__ import annotations + +from .agent_loop import AgentLoop, AgentTurnResult +from .approval_gate import ApprovalGate, ProposedResult, ToolResult +from .context_builder import ContextBuilder +from .reasoning_controller import ReasoningController +from .tool_policy import ToolCategory, ToolDescriptor, ToolPolicy +from .tool_registry import ToolRegistry + +__all__ = [ + "AgentLoop", + "AgentTurnResult", + "ApprovalGate", + "ContextBuilder", + "Event", + "ProposedResult", + "ReasoningController", + "ToolCategory", + "ToolDescriptor", + "ToolPolicy", + "ToolRegistry", + "ToolResult", +] diff --git a/services/planning_service/planning_service/harness/agent_loop.py b/services/planning_service/planning_service/harness/agent_loop.py new file mode 100644 index 0000000..08f6f0e --- /dev/null +++ b/services/planning_service/planning_service/harness/agent_loop.py @@ -0,0 +1,322 @@ +"""Generic multi-turn agent loop for both chat and planner modes. + +The loop owns: +- binding the model to the unified tool set, +- invoking the model and detecting tool calls, +- resolving each tool call through the approval gate, +- extracting reasoning/thinking, +- emitting events (in streaming mode) or returning a result object. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Iterator + +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import ToolMessage +from langchain_core.tools import BaseTool + +from .approval_gate import ApprovalGate +from .context_builder import ContextBuilder +from .reasoning_controller import ReasoningController +from .tool_policy import ToolCategory, ToolDescriptor +from .tool_registry import ToolRegistry + + +def _llm_friendly_result(result: Any) -> Any: + """Replace large binary payloads with placeholders for the LLM context.""" + if isinstance(result, dict) and "image_url" in result: + out = dict(result) + out["image_url"] = "[image data shown to user in chat]" + return out + return result + + +@dataclass(frozen=True) +class AgentTurnResult: + """Result of one agent turn.""" + + response: str + thinking: str | None = None + tool_calls: list[dict[str, Any]] = field(default_factory=list) + proposed_actions: list[dict[str, Any]] = field(default_factory=list) + + +class AgentLoop: + """Multi-turn tool-calling loop.""" + + def __init__( + self, + model: BaseChatModel, + tool_registry: ToolRegistry, + approval_gate: ApprovalGate, + context_builder: ContextBuilder, + reasoning: ReasoningController | None = None, + *, + propose_only: bool = False, + allow_actions: bool = True, + max_iterations: int = 5, + include_reasoning: bool = False, + ) -> None: + self._model = model + self._registry = tool_registry + self._approval_gate = approval_gate + self._context_builder = context_builder + self._reasoning = reasoning or ReasoningController() + self._propose_only = propose_only + self._allow_actions = allow_actions + self._max_iterations = max_iterations + self._include_reasoning = include_reasoning + self._action_tool_names = { + d.name + for d in tool_registry.descriptors() + if d.policy.category == ToolCategory.ACT + } + + def run(self, messages: list[dict[str, Any]]) -> AgentTurnResult: + """Run the loop synchronously and return the final result.""" + lc_messages = self._context_builder.chat_messages( + messages, include_reasoning=self._include_reasoning + ) + tool_map = self._tool_map() + tool_log: list[dict[str, Any]] = [] + proposed: list[dict[str, Any]] = [] + last_response: Any = None + final_text = "" + final_thinking: str | None = None + + for _ in range(self._max_iterations): + response = self._model.invoke(lc_messages) + last_response = response + tool_calls = getattr(response, "tool_calls", None) or [] + if not tool_calls: + final_text = str(response.content or "") + final_thinking = self._reasoning.extract(response) + break + + lc_messages.append(response) + for call in tool_calls: + name = call.get("name") + args = call.get("args", {}) + tool_call_id = call.get("id", "") + result = self._invoke_tool(name, args, tool_map) + tool_log.append({"name": name, "args": args, "result": result}) + if isinstance(result, dict) and result.get("status") == "proposed": + proposed.append( + { + "kind": result.get("kind", name), + "params": result.get("params", args), + } + ) + lc_messages.append( + ToolMessage( + content=json.dumps(_llm_friendly_result(result)), + tool_call_id=tool_call_id, + name=name, + ) + ) + else: + final_text = str(getattr(last_response, "content", "") or "") + final_thinking = self._reasoning.extract(last_response) + if not final_text: + final_text = ( + "I ran too many tool calls without finishing. Please try again." + ) + + final_text = self._reasoning.strip_from_text(final_text) + return AgentTurnResult( + response=final_text, + thinking=final_thinking, + tool_calls=tool_log, + proposed_actions=proposed, + ) + + def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: + """Run the loop and yield SSE-style events.""" + lc_messages = self._context_builder.chat_messages( + messages, include_reasoning=self._include_reasoning + ) + tool_map = self._tool_map() + tool_log: list[dict[str, Any]] = [] + proposed: list[dict[str, Any]] = [] + last_response: Any = None + + for _ in range(self._max_iterations): + response = self._model.invoke(lc_messages) + last_response = response + tool_calls = getattr(response, "tool_calls", None) or [] + if not tool_calls: + break + + lc_messages.append(response) + for call in tool_calls: + name = call.get("name") + args = call.get("args", {}) + tool_call_id = call.get("id", "") + result = self._invoke_tool(name, args, tool_map) + tool_log.append({"name": name, "args": args, "result": result}) + if isinstance(result, dict) and result.get("status") == "proposed": + proposed.append( + { + "kind": result.get("kind", name), + "params": result.get("params", args), + } + ) + yield { + "type": "tool_call", + "name": name, + "args": args, + "result": result, + } + lc_messages.append( + ToolMessage( + content=json.dumps(_llm_friendly_result(result)), + tool_call_id=tool_call_id, + name=name, + ) + ) + + yield {"type": "meta", "tool_calls": tool_log, "proposed_actions": proposed} + + tool_turn_thinking = self._reasoning.extract(last_response) + if tool_turn_thinking: + yield {"type": "thinking", "content": tool_turn_thinking} + + buffer = "" + streamed_reasoning: list[str] = [] + streamed_reasoning_emitted = bool(tool_turn_thinking) + for chunk in self._model.stream(lc_messages): + for event in self._reasoning.stream_chunks( + chunk, + accumulated_reasoning=streamed_reasoning, + emitted=streamed_reasoning_emitted, + ): + streamed_reasoning_emitted = True + yield event + + content = getattr(chunk, "content", None) + if not content: + continue + buffer += str(content) + for event in self._reasoning.split_text(buffer): + if event["type"] == "delta": + if event["content"]: + yield event + buffer = "" + elif event["type"] == "thinking": + yield event + # Any trailing text in the buffer after the think block + # will be re-processed in the next iteration. + + if buffer: + yield {"type": "delta", "content": buffer} + + def plan_request(self, request: str, *, max_iterations: int = 3) -> AgentTurnResult: + """Planner-mode loop: gather introspection, collect action proposals. + + Action tools are resolved through the approval gate; callers should + construct the loop with ``propose_only=True`` (or ``allow_actions=False``) + so physical actions are not executed during planning. + """ + lc_messages = self._context_builder.planner_messages(request) + tool_map = self._tool_map() + tool_log: list[dict[str, Any]] = [] + last_response: Any = None + final_text = "" + final_thinking: str | None = None + + for _ in range(max_iterations): + response = self._model.invoke(lc_messages) + last_response = response + tool_calls = getattr(response, "tool_calls", None) or [] + if not tool_calls: + final_text = str(response.content or "") + final_thinking = self._reasoning.extract(response) + break + + # In planning mode, once the model emits action tool calls we + # have the plan and should stop rather than asking it to continue. + action_calls = [ + c for c in tool_calls if c.get("name") in self._action_tool_names + ] + if action_calls: + for call in tool_calls: + name = call.get("name") + args = call.get("args", {}) + result = self._invoke_tool(name, args, tool_map) + tool_log.append({"name": name, "args": args, "result": result}) + final_text = str(response.content or "") + final_thinking = self._reasoning.extract(response) + break + + lc_messages.append(response) + for call in tool_calls: + name = call.get("name") + args = call.get("args", {}) + tool_call_id = call.get("id", "") + result = self._invoke_tool(name, args, tool_map) + tool_log.append({"name": name, "args": args, "result": result}) + lc_messages.append( + ToolMessage( + content=json.dumps(_llm_friendly_result(result)), + tool_call_id=tool_call_id, + name=name, + ) + ) + else: + final_text = str(getattr(last_response, "content", "") or "") + final_thinking = self._reasoning.extract(last_response) + if not final_text: + final_text = ( + "I ran too many tool calls without finishing. Please try again." + ) + + final_text = self._reasoning.strip_from_text(final_text) + return AgentTurnResult( + response=final_text, + thinking=final_thinking, + tool_calls=tool_log, + proposed_actions=[], + ) + + def _tool_map(self) -> dict[str, BaseTool]: + return { + t.name: t + for t in self._registry.langchain_tools(resolve=self._resolve_tool) + } + + def _resolve_tool( + self, descriptor: ToolDescriptor, params: dict[str, Any] + ) -> dict[str, Any]: + if descriptor.is_introspection: + if descriptor.execute is not None: + return descriptor.execute(params) + return {"error": f"introspection tool {descriptor.name!r} has no executor"} + result = self._approval_gate.resolve( + descriptor, + params, + propose_only=self._propose_only, + allow_actions=self._allow_actions, + ) + return { + "status": result.status, + "kind": result.kind, + "params": result.params, + "note": result.note, + "error": result.error, + } + + def _invoke_tool( + self, name: str | None, args: dict[str, Any], tool_map: dict[str, BaseTool] + ) -> dict[str, Any]: + if name is None: + return {"error": "tool call missing name"} + tool = tool_map.get(name) + if tool is None: + return {"error": f"unknown tool {name!r}"} + try: + return tool.invoke(args) + except Exception as err: # noqa: BLE001 + return {"error": f"{type(err).__name__}: {err}"} diff --git a/services/planning_service/planning_service/harness/approval_gate.py b/services/planning_service/planning_service/harness/approval_gate.py new file mode 100644 index 0000000..19655b1 --- /dev/null +++ b/services/planning_service/planning_service/harness/approval_gate.py @@ -0,0 +1,110 @@ +"""Approval gate: decide whether a tool call executes or becomes a proposal. + +This is the single place where "can the robot do this right now?" is +answered. It combines the tool's policy with the conversation mode +(propose_only / allow_actions) and the safety gate. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from safety_service import UnsafeActionError, validate as safety_validate +from twfarmbot_core.actions import ActionRegistry +from twfarmbot_core.domain import Action + +from .tool_policy import ToolDescriptor + + +@dataclass(frozen=True) +class ToolResult: + """Result of an executed or proposed tool call.""" + + status: str # "ok", "proposed", "error", "noop" + kind: str + params: dict[str, Any] + note: str = "" + error: str = "" + + +@dataclass(frozen=True) +class ProposedResult(ToolResult): + """Convenience marker for a proposed action.""" + + def __init__(self, kind: str, params: dict[str, Any]) -> None: + super().__init__( + status="proposed", + kind=kind, + params=params, + note="This action is proposed and requires user approval.", + ) + + +class ApprovalGate: + """Execute or propose a tool call according to policy and mode.""" + + def __init__( + self, registry: ActionRegistry, *, planning_mode: bool = False + ) -> None: + self._registry = registry + self._planning_mode = planning_mode + + def resolve( + self, + descriptor: ToolDescriptor, + params: dict[str, Any], + *, + propose_only: bool, + allow_actions: bool, + ) -> ToolResult: + policy = descriptor.policy + kind = descriptor.name + + if descriptor.is_introspection: + # Read-only / analysis tools never require approval. + return ToolResult(status="noop", kind=kind, params=params) + + # In planning mode we never execute physical actions; we only collect + # proposed actions so the planner can return them for later approval. + if self._planning_mode: + return ProposedResult(kind, params) + + # ACT tools that require approval become proposals in proposal mode + # or when actions are not allowed at all. + if policy.requires_approval: + if propose_only or not allow_actions: + return ProposedResult(kind, params) + + # Some tools are always immediate (e.g. e_stop), but still gated by + # allow_actions unless explicitly allowed without user confirmation. + if not policy.allow_without_user and not allow_actions: + return ProposedResult(kind, params) + + # Execute through the registry. Safety validation runs inside dispatch. + try: + action = Action(kind=kind, params=dict(params)) + result = self._registry.dispatch(action) + return ToolResult(status="ok", kind=kind, params=result.params) + except UnsafeActionError as err: + return ToolResult( + status="error", + kind=kind, + params=params, + error=f"unsafe: {err}", + ) + except Exception as err: # noqa: BLE001 + return ToolResult( + status="error", + kind=kind, + params=params, + error=f"{type(err).__name__}: {err}", + ) + + def check_safety(self, kind: str, params: dict[str, Any]) -> None: + """Raise ``UnsafeActionError`` if the action would be rejected. + + Exposed so callers (e.g. the planner JSON fallback) can validate + without dispatching. + """ + safety_validate(Action(kind=kind, params=dict(params))) diff --git a/services/planning_service/planning_service/harness/context_builder.py b/services/planning_service/planning_service/harness/context_builder.py new file mode 100644 index 0000000..bd57c7c --- /dev/null +++ b/services/planning_service/planning_service/harness/context_builder.py @@ -0,0 +1,223 @@ +"""Build system prompts and conversation context for the agent loop. + +Tool lists and approval notes are generated from ``ToolRegistry`` so the +prompts stay in sync with the code. +""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +from spatial_service import format_world_context + +from .tool_policy import ToolCategory +from .tool_registry import ToolRegistry + +_CHAT_HEADER = """You are TWFarmBot Assistant, a helpful, concise farm-robot operator. + +You can chat naturally with the user, answer questions about the robot and +garden, and perform actions by calling tools. Always respond in the same +language the user writes in. + +""" + +_CHAT_FOOTER = """ +Guidelines: +- Before moving to a named zone, call `list_zones` to get its centre. +- Keep answers short and actionable. Confirm what you did and any relevant + sensor/position readings. +- If a request is unsafe or impossible, refuse and explain why. +- When you call analysis tools (`analyze_image`, `segment_image`, + `visualize_image_features`, `estimate_traversability`), you cannot see the + returned images yourself. Use the numeric metrics and class lists the tools + provide, then state what analysis was run and that the images are shown to + the user. +- Some actions require user approval (see tool list). When a tool returns a + proposed-action marker, say the proposal briefly, note that it requires + approval, and stop — the interface shows Approve/Reject buttons. +- When a question depends on the live garden state, do not rely on a single + tool result. Gather and cross-check evidence across multiple tools and + reason about the combined picture. For example: + - If an image is dark or segmentation shows nothing, call `take_photo` for + a fresh frame and/or `get_position` to see where the camera is. + - Combine `get_position`, `list_zones`, and `get_garden` to know which zone + the camera is pointing at and whether the view matches expectations. + - Use `segment_image` when you need numeric presence/absence of classes. + - If evidence is still unclear after a few tool calls, say so and propose a + concrete next step (e.g. move to a zone with better lighting). +- Use the reasoning/thinking space to plan your tool calls before giving the + final answer; the user will see the reasoning as a collapsible pill. +""" + +_PROPOSE_ONLY_APPENDIX = """ +IMPORTANT: You are in proposal mode. When the user asks you to perform one +or more actions (move, water, take_photo, etc.), you MUST call the +corresponding action tool to register the proposal. The tool will return a +proposed-action marker; do NOT describe the action in text without calling +the tool first. State the proposal briefly, note that it requires approval, +and stop. Do NOT ask the user a yes/no approval question and do NOT say the +action is done — the interface shows Approve/Reject buttons. +""" + +_PLANNER_HEADER = """You are a task planner for an autonomous farm robot. + +You translate natural-language requests into a strict, ordered list of +machine actions. The robot has a fixed action vocabulary; you MUST only emit +kinds that appear in the vocabulary below. Do not invent kinds. + +Output format (REQUIRED): +- Return a single JSON object with two keys: `actions` and `rationale`. +- `actions` is a JSON array, in execution order. +- Each action is {"kind": , "params": }. +- Keep `rationale` to one short sentence. +- Do not wrap the JSON in markdown. Do not add commentary outside the JSON. + +""" + +_PLANNER_FOOTER = """ +Grounding names to coordinates: +- Match names LOOSELY: "the tomatoes", "tomato", "tomato zone", and + "Tomato Zone" all refer to the same entry. Match by stem + (tomato/herbs/camera) not by exact string. +- To "move to a named zone", use its `center` from `list_zones`. +- To "move to a named entity", use its `(x, y, z)` from the world model. +- DEFAULT to producing a plan. Only return `actions: []` when the request is + genuinely impossible. +- If the request is ambiguous, pick the most specific match and explain in + `rationale`. +- If the request is unsafe or impossible, return `actions: []` and explain + in `rationale`. +""" + + +class ContextBuilder: + """Build prompts and LangChain message lists.""" + + def __init__( + self, + tool_registry: ToolRegistry, + world: Any = None, + propose_only: bool = False, + ) -> None: + self._registry = tool_registry + self._world = world + self._propose_only = propose_only + + def chat_system_prompt(self) -> str: + parts = [_CHAT_HEADER] + parts.append(self._render_tool_section()) + parts.append(_CHAT_FOOTER) + if self._propose_only: + parts.append(_PROPOSE_ONLY_APPENDIX) + parts.append( + "\nRegistered action kinds you can use: " + + ", ".join(sorted(self._registry.by_name())) + + "." + ) + return "\n".join(parts) + + def planner_system_prompt(self) -> str: + parts = [_PLANNER_HEADER] + parts.append(self._render_tool_section(for_planner=True)) + parts.append(_PLANNER_FOOTER) + return "\n".join(parts) + + def chat_messages( + self, + messages: list[dict[str, Any]], + *, + include_reasoning: bool = False, + ) -> list[SystemMessage | HumanMessage | AIMessage | ToolMessage]: + system = self.chat_system_prompt() + world_context = ( + format_world_context(self._world) if self._world is not None else None + ) + if world_context: + system += "\n\nCurrent world model:\n" + world_context + out: list[SystemMessage | HumanMessage | AIMessage] = [ + SystemMessage(content=system) + ] + for msg in messages: + role = msg.get("role", "") + content = str(msg.get("content", "") or "") + if role == "user": + out.append(HumanMessage(content=content)) + elif role == "assistant": + kwargs: dict[str, Any] = {} + if include_reasoning: + thinking = msg.get("thinking") + if thinking: + kwargs["additional_kwargs"] = { + "reasoning_content": str(thinking) + } + out.append(AIMessage(content=content, **kwargs)) + elif role == "tool": + # Preserve tool results across multi-turn conversation. + out.append( + ToolMessage( + content=content, + tool_call_id=str(msg.get("tool_call_id", "")), + name=str(msg.get("name", "")), + ) + ) + return out + + def planner_messages(self, request: str) -> list[SystemMessage | HumanMessage]: + system = self.planner_system_prompt() + world_context = ( + format_world_context(self._world) if self._world is not None else None + ) + if world_context: + system += "\n\nCurrent world model:\n" + world_context + return [ + SystemMessage(content=system), + HumanMessage(content=request), + ] + + def _render_tool_section(self, for_planner: bool = False) -> str: + lines: list[str] = [] + descriptors = self._registry.descriptors() + + read_tools = [d for d in descriptors if d.policy.category == ToolCategory.READ] + analyze_tools = [ + d for d in descriptors if d.policy.category == ToolCategory.ANALYZE + ] + act_tools = [d for d in descriptors if d.policy.category == ToolCategory.ACT] + + if for_planner: + if act_tools: + lines.append("Available action kinds:") + for d in act_tools: + lines.append(f"- `{d.name}` — {d.policy.description}") + if read_tools: + lines.append("\nRead-only introspection tools:") + for d in read_tools: + lines.append(f"- `{d.name}` — {d.policy.description}") + return "\n".join(lines) + + if read_tools: + lines.append("Read-only tools (use these to answer questions):") + for d in read_tools: + approval = ( + " **Requires user approval.**" if d.policy.requires_approval else "" + ) + lines.append(f"- `{d.name}` — {d.policy.description}{approval}") + + if analyze_tools: + lines.append("\nAnalysis tools:") + for d in analyze_tools: + lines.append(f"- `{d.name}` — {d.policy.description}") + + if act_tools: + lines.append("\nExecution tools (use these to change the robot state):") + for d in act_tools: + approval = ( + " **Requires user approval.**" + if d.policy.requires_approval + else " Executes immediately." + ) + lines.append(f"- `{d.name}` — {d.policy.description}{approval}") + + return "\n".join(lines) diff --git a/services/planning_service/planning_service/harness/reasoning_controller.py b/services/planning_service/planning_service/harness/reasoning_controller.py new file mode 100644 index 0000000..1a9debe --- /dev/null +++ b/services/planning_service/planning_service/harness/reasoning_controller.py @@ -0,0 +1,86 @@ +"""Extract and surface model reasoning / thinking. + +Provider-specific reasoning fields (DeepSeek ``reasoning_content``, +OpenRouter ``reasoning``, Claude ``thinking``) and explicit ```` +tags are normalized into plain ``thinking`` events. +""" + +from __future__ import annotations + +import re +from typing import Any, Iterator + +THINK_TAG_RE = re.compile(r"(.*?)", re.DOTALL) + + +class ReasoningController: + """Extract reasoning from LangChain messages and streaming chunks.""" + + @staticmethod + def extract(message: Any) -> str | None: + """Extract thinking from a complete LangChain message.""" + content = str(getattr(message, "content", "") or "") + match = THINK_TAG_RE.search(content) + if match: + thinking = match.group(1).strip() + return thinking if thinking else None + + for key in ("reasoning_content", "thinking", "reasoning"): + value = getattr(message, "response_metadata", {}).get(key) or getattr( + message, "additional_kwargs", {} + ).get(key) + if value: + return str(value).strip() or None + return None + + @staticmethod + def strip_from_text(text: str) -> str: + """Remove ``...`` blocks from visible text.""" + return THINK_TAG_RE.sub("", text).strip() + + @classmethod + def stream_chunks( + cls, + chunk: Any, + *, + accumulated_reasoning: list[str], + emitted: bool, + ) -> Iterator[dict[str, Any]]: + """Yield any ``thinking`` event that should be emitted for this chunk. + + The reasoning is yielded once, right before the first content chunk + after reasoning begins. This keeps reasoning visible above the final + answer while still allowing streaming text. + """ + reasoning = getattr(chunk, "additional_kwargs", {}).get("reasoning") + if reasoning: + accumulated_reasoning.append(str(reasoning)) + content = getattr(chunk, "content", None) + if content and accumulated_reasoning and not emitted: + yield {"type": "thinking", "content": "".join(accumulated_reasoning)} + + @classmethod + def split_text(cls, text: str) -> Iterator[dict[str, Any]]: + """Split text into alternating delta and thinking events. + + Yields ``{"type": "delta", ...}`` and ``{"type": "thinking", ...}`` + pieces so that ```` blocks appear as reasoning rather than + visible output. + """ + buffer = text + while True: + start = buffer.find("") + end = buffer.find("") + if start != -1 and end != -1 and end > start: + prefix = buffer[:start] + think = buffer[start + 7 : end] + suffix = buffer[end + 8 :] + if prefix: + yield {"type": "delta", "content": prefix} + if think: + yield {"type": "thinking", "content": think} + buffer = suffix + continue + break + if buffer: + yield {"type": "delta", "content": buffer} diff --git a/services/planning_service/planning_service/harness/tool_policy.py b/services/planning_service/planning_service/harness/tool_policy.py new file mode 100644 index 0000000..89855a2 --- /dev/null +++ b/services/planning_service/planning_service/harness/tool_policy.py @@ -0,0 +1,50 @@ +"""Tool policy primitives. + +Every tool exposed to the LLM carries a ``ToolPolicy`` that declares: +- what category it belongs to (read, act, analyze), +- whether it requires user approval, +- which safety validators apply, +- a model-facing description. + +This is the single source of truth for "can this tool mutate the robot?" +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable + +from pydantic import BaseModel + + +class ToolCategory(str, Enum): + READ = "read" + ACT = "act" + ANALYZE = "analyze" + + +@dataclass(frozen=True) +class ToolPolicy: + """Behavioral policy for one tool.""" + + category: ToolCategory + requires_approval: bool = False + allow_without_user: bool = True # if False, even allow_actions=True cannot auto-run + safety_rules: tuple[str, ...] = () # names of safety_service validators + description: str = "" + + +@dataclass(frozen=True) +class ToolDescriptor: + """Complete description of one LLM-facing tool.""" + + name: str + args_schema: type[BaseModel] + policy: ToolPolicy + execute: Callable[..., dict[str, Any]] | None = None + is_introspection: bool = False + + @property + def is_read_only(self) -> bool: + return self.policy.category in (ToolCategory.READ, ToolCategory.ANALYZE) diff --git a/services/planning_service/planning_service/harness/tool_registry.py b/services/planning_service/planning_service/harness/tool_registry.py new file mode 100644 index 0000000..4b03bd4 --- /dev/null +++ b/services/planning_service/planning_service/harness/tool_registry.py @@ -0,0 +1,244 @@ +"""Build a unified tool registry from the ActionRegistry and introspection tools.""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable + +from langchain_core.tools import BaseTool, StructuredTool, tool +from pydantic import BaseModel +from twfarmbot_core.actions import ActionRegistry + +from .. import introspection +from ..tools import ( + FindHomeArgs, + MountToolArgs, + MoveArgs, + ReadPinArgs, + SendMessageArgs, + WaterArgs, + WritePinArgs, +) +from .tool_policy import ToolCategory, ToolDescriptor, ToolPolicy + +# Single source of truth for action-tool policies. +_ACTION_POLICIES: dict[str, ToolPolicy] = { + "move": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + safety_rules=("move",), + description="Move the gantry to absolute X/Y/Z mm.", + ), + "water": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + safety_rules=("water",), + description="Turn the pump on for N seconds.", + ), + "find_home": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + description="Run the end-stop homing sequence.", + ), + "write_pin": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + description="Write a value (0/1) to a GPIO pin.", + ), + "mount_tool": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + description="Mount a named tool on the gantry.", + ), + "dismount_tool": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + description="Dismount the currently mounted tool.", + ), + "read_pin": ToolPolicy( + ToolCategory.READ, + requires_approval=False, + description="Read a GPIO pin value.", + ), + "take_photo": ToolPolicy( + ToolCategory.READ, + requires_approval=False, + description="Trigger the camera to take a photo.", + ), + "send_message": ToolPolicy( + ToolCategory.READ, + requires_approval=False, + description="Show a message to the user.", + ), + "e_stop": ToolPolicy( + ToolCategory.ACT, + requires_approval=False, + allow_without_user=True, + description="Emergency stop — halt the robot immediately.", + ), +} + +_ACTION_SCHEMAS: dict[str, type[BaseModel]] = { + "move": MoveArgs, + "water": WaterArgs, + "find_home": FindHomeArgs, + "read_pin": ReadPinArgs, + "write_pin": WritePinArgs, + "send_message": SendMessageArgs, + "mount_tool": MountToolArgs, +} + +_INTROSPECTION_CATEGORIES: dict[str, ToolCategory] = { + "list_endpoints": ToolCategory.READ, + "get_health": ToolCategory.READ, + "get_position": ToolCategory.READ, + "get_status": ToolCategory.READ, + "get_messages": ToolCategory.READ, + "get_garden": ToolCategory.READ, + "list_zones": ToolCategory.READ, + "list_endpoints_action": ToolCategory.READ, + "get_pins": ToolCategory.READ, + "get_positions": ToolCategory.READ, + "get_images": ToolCategory.READ, + "analyze_image": ToolCategory.ANALYZE, + "segment_image": ToolCategory.ANALYZE, + "visualize_image_features": ToolCategory.ANALYZE, + "estimate_traversability": ToolCategory.ANALYZE, +} + + +class ToolRegistry: + """Unified registry of LLM-facing tools. + + Combines the physical action vocabulary from ``ActionRegistry`` with + the read-only introspection tools. Each tool is represented by a + ``ToolDescriptor`` that carries its policy, schema, and execution logic. + """ + + def __init__( + self, + registry: ActionRegistry, + system_state: introspection.SystemStateProvider | None = None, + ) -> None: + self._registry = registry + self._system_state = system_state + self._descriptors: list[ToolDescriptor] | None = None + self._by_name: dict[str, ToolDescriptor] | None = None + + def descriptors(self) -> list[ToolDescriptor]: + if self._descriptors is None: + self._descriptors = list(self._build_action_descriptors()) + list( + self._build_introspection_descriptors() + ) + return self._descriptors + + def by_name(self) -> dict[str, ToolDescriptor]: + if self._by_name is None: + self._by_name = {d.name: d for d in self.descriptors()} + return self._by_name + + def langchain_tools( + self, + resolve: ( + Callable[[ToolDescriptor, dict[str, Any]], dict[str, Any]] | None + ) = None, + ) -> list[BaseTool]: + """Return LangChain tools for all registered descriptors. + + ``resolve`` is called for every invocation; it receives the + descriptor and parsed arguments and returns the tool result. When + omitted, introspection tools execute normally and action tools + return a placeholder marker. + """ + out: list[BaseTool] = [] + for descriptor in self.descriptors(): + out.append(_descriptor_to_langchain(descriptor, resolve)) + return out + + def _build_action_descriptors(self) -> list[ToolDescriptor]: + out: list[ToolDescriptor] = [] + for kind in self._registry.kinds(): + policy = _ACTION_POLICIES.get(kind) + if policy is None: + # Unknown action kind: still expose it read-only with a safe default. + policy = ToolPolicy( + ToolCategory.READ, + requires_approval=False, + description=f"Action kind '{kind}'.", + ) + out.append( + ToolDescriptor( + name=kind, + args_schema=_ACTION_SCHEMAS.get(kind, _NoArgs), + policy=policy, + execute=None, + is_introspection=False, + ) + ) + return out + + def _build_introspection_descriptors(self) -> list[ToolDescriptor]: + out: list[ToolDescriptor] = [] + if self._system_state is None: + return out + for lc_tool in introspection.build_introspection_tools(self._system_state): + name = lc_tool.name + schema = _schema_from_tool(lc_tool) + category = _INTROSPECTION_CATEGORIES.get(name, ToolCategory.READ) + policy = ToolPolicy( + category, + requires_approval=False, + description=lc_tool.description or "", + ) + out.append( + ToolDescriptor( + name=name, + args_schema=schema, + policy=policy, + execute=lambda args, t=lc_tool: t.invoke(args), # type: ignore[arg-type] + is_introspection=True, + ) + ) + return out + + +class _NoArgs(BaseModel): + """Fallback schema for tools that take no arguments.""" + + pass + + +def _schema_from_tool(lc_tool: BaseTool) -> type[BaseModel]: + """Best-effort extraction of the args schema from a LangChain tool.""" + if isinstance(lc_tool.args_schema, type) and issubclass( + lc_tool.args_schema, BaseModel + ): + return lc_tool.args_schema + # Build a permissive schema from the tool's JSON schema if needed. + return _NoArgs + + +def _descriptor_to_langchain( + descriptor: ToolDescriptor, + resolve: Callable[[ToolDescriptor, dict[str, Any]], dict[str, Any]] | None = None, +) -> BaseTool: + """Wrap a descriptor as an invocable LangChain tool.""" + + def _run(**kwargs: Any) -> dict[str, Any]: + if descriptor.is_introspection and descriptor.execute is not None: + return descriptor.execute(kwargs) + if resolve is not None: + return resolve(descriptor, kwargs) + # Schema-only action tool: should never be invoked directly. + return {"status": "noop", "kind": descriptor.name, "params": kwargs} + + # Preserve the descriptor description unless it is empty. + description = descriptor.policy.description or descriptor.name + + return StructuredTool.from_function( + func=_run, + name=descriptor.name, + description=description, + args_schema=descriptor.args_schema, + return_direct=False, + ) diff --git a/services/planning_service/planning_service/prompt.py b/services/planning_service/planning_service/prompt.py index cdc74d3..9900bda 100644 --- a/services/planning_service/planning_service/prompt.py +++ b/services/planning_service/planning_service/prompt.py @@ -1,12 +1,8 @@ -"""Prompt construction for the planner. +"""Shared Pydantic schemas and user prompt helper. -The planner is a pure text-in / JSON-out task: the system prompt -describes the available action vocabulary, the user message is the -natural-language request (plus optional world-model context). The -model's job is to return a JSON object matching ``PlannerResponse``. - -Keeping the schema declarative (Pydantic) means the parser can validate -LLM output structurally before we ever look at it. +System prompts are now generated by ``harness.ContextBuilder`` from the +unified ``ToolRegistry`` so the model always sees the exact tool vocabulary +and approval rules that are configured in code. """ from __future__ import annotations @@ -37,180 +33,6 @@ class PlannerResponse(BaseModel): ) -SYSTEM_PROMPT = """You are a task planner for an autonomous farm robot. - -You translate natural-language requests into a strict, ordered list of -machine actions. The robot has a fixed action vocabulary; you MUST only -emit kinds that appear in the vocabulary below. Do not invent kinds. - -Output format (REQUIRED): -- Return a single JSON object with two keys: `actions` and `rationale`. -- `actions` is a JSON array, in execution order. -- Each action is {{"kind": , "params": }}. -- Keep `rationale` to one short sentence. -- Do not wrap the JSON in markdown. Do not add commentary outside the JSON. - -Available action kinds: -__ACTION_VOCABULARY__ - -Vocabulary semantics: -- `move` is the ONLY way to send the robot to a position. It needs - literal `x`, `y`, `z` coordinates in millimetres. -- `find_home` runs the end-stop homing sequence (mechanical calibration - against the limit switches). It does NOT send the robot to the - (0, 0, 0) waypoint — it moves until it hits the axes' physical - limits. Use `find_home` only when the user asks to calibrate, home, - or "find home" the axes. To return to the (0, 0, 0) waypoint, use - `move` with `x: 0, y: 0, z: 0`. -- "Home", "origin", "waypoint home", "go back" => `move(x=0, y=0, z=0)`. -- "Home the axes", "calibrate", "find home" (verb form) => `find_home`. - -You have read-only introspection tools. USE THEM when the answer -depends on live state, not the static world model. - -- ``get_position`` — where the gantry is right now. Call this before - any "where am I?" or "am I there yet?" type question. -- ``get_health`` — whether the FarmBot is connected. -- ``list_zones`` — every zone with its bounds and pre-computed - `center`. Call this when the user names a location; the centre is - the right coordinate to move to. -- ``list_endpoints`` — every HTTP endpoint the API exposes. Use this - to discover what reads/writes are available before planning. -- ``get_garden`` — full world model (bounds, zones, entities, camera - pose) in one call. Use this when you need several facts at once. -- ``read_pin`` / ``get_status`` / ``get_messages`` — per-pin values, - full status tree, recent MQTT traffic. -- ``get_pins`` / ``get_positions`` — named GPIO pins and gantry - presets (Home, Bed, …). -- ``get_images`` — recent camera images. - -When the user names a location, prefer calling ``list_zones`` over -guessing coordinates from the static world model in this prompt — the -tool returns pre-computed centres and is the source of truth. - -Grounding names to coordinates: -- Match names LOOSELY: "the tomatoes", "tomato", "tomato zone", and - "Tomato Zone" all refer to the same entry. Match by stem - (tomato/herbs/camera) not by exact string. -- To "move to a named zone", use its `center` from ``list_zones``. -- To "move to a named entity", use its `(x, y, z)` from the world - model. -- DEFAULT to producing a plan. Only return `actions: []` when the - request is genuinely impossible. -- If the request is ambiguous, pick the most specific match and - explain in `rationale`. - -Constraints: -- `move` params: `x`, `y`, `z` in millimetres (floats). All three required. -- `water` params: `seconds` (positive float, max 300). -- `find_home` params: optional `axis` ("x" | "y" | "z" | "all"), `speed` (1..100). -- `read_pin` params: `pin` (int), `mode` ("digital" | "analog"). -- `write_pin` params: `pin` (int), `value` (0|1), `mode` ("digital" | "analog"). -- `take_photo` params: none. -- `send_message` params: `message` (string), `message_type` ("info" | "success" | "warn" | "error"). -- `mount_tool` params: `tool_name` (string). `dismount_tool` params: none. -- `e_stop` params: none. -- If the request is ambiguous, prefer the smallest safe plan and add a note in `rationale`. -- If the request is unsafe or impossible, return `actions: []` and explain in `rationale`. -""" - - -def build_system_prompt(action_vocabulary: list[str]) -> str: - return SYSTEM_PROMPT.replace( - "__ACTION_VOCABULARY__", ", ".join(sorted(action_vocabulary)) - ) - - -CHAT_SYSTEM_PROMPT = """You are TWFarmBot Assistant, a helpful, concise farm-robot operator. - -You can chat naturally with the user, answer questions about the robot and -garden, and perform actions by calling tools. Always respond in the same -language the user writes in. - -Read-only tools (use these to answer questions): -- `get_health` — FarmBot connection status and registered actions. -- `get_position` — current gantry X/Y/Z in mm. -- `get_status` — full status tree (use only when detailed state is needed). -- `get_garden` — configured world model (bounds, zones, entities, camera). -- `list_zones` — every zone with bounds and centre coordinates. -- `get_pins` — named GPIO pins. -- `get_positions` — named gantry presets (Home, Bed, …). -- `get_images` — recent camera images. -- `analyze_image` — open-language similarity map for the latest camera image. Provide a prompt like "plants", "weeds", or "dry soil". Returns only the similarity heatmap image; it does NOT provide numeric detection metrics. Use it for visual exploration, not as the only source of evidence. -- `segment_image` — zero-shot segmentation. Provide comma-separated classes like "plant, weed, soil". Returns class percentages, detected/not-detected class lists, and the dominant class. -- `visualize_image_features` — PCA feature visualization. Optionally set `n_clusters` (2..20, default 6). -- `estimate_traversability` — traversability map. Provide a prompt like "path" or "flat ground". -- `get_messages` — recent MQTT messages. - -Execution tools (use these to change the robot state): -- `move` — move the gantry to absolute X/Y/Z mm. **Requires user approval.** -- `water` — turn the pump on for N seconds. **Requires user approval.** -- `take_photo` — capture a photo with the FarmBot camera. Executes immediately. -- `find_home` — run the end-stop homing sequence. **Requires user approval.** -- `read_pin` / `write_pin` — read or set a GPIO pin. `write_pin` requires approval. -- `send_message` — show a message on the FarmBot. Executes immediately. -- `mount_tool` / `dismount_tool` — change the mounted tool. **Requires user approval.** -- `e_stop` — emergency stop. Executes immediately. - -Guidelines: -- Before moving to a named zone, call `list_zones` to get its centre. -- Keep answers short and actionable. Confirm what you did and any relevant - sensor/position readings. -- If a request is unsafe or impossible, refuse and explain why. -- When you call `analyze_image`, `segment_image`, `visualize_image_features`, - or `estimate_traversability`, you cannot see the returned analysis images - yourself. Only the raw model outputs are available: - - `analyze_image` returns the similarity heatmap image and the prompt used. - It does NOT provide detection metrics, so do not use it to answer - yes/no "is X present?" questions. - - `segment_image` returns the segmentation images, `class_scores` - (percentage per class), `detected_classes`, `not_detected_classes`, and - `dominant_class`. Use `segment_image` (not `analyze_image`) when the user - asks whether something like "plants" or "weeds" is present. - - `visualize_image_features` returns the PCA images and `n_clusters`. - - `estimate_traversability` returns the traversability image and the prompt. - State what analysis was run and that the result images are shown to the user. -- Some actions execute immediately (take_photo, read_pin, send_message, e_stop); - the rest are proposed for user approval. Respond accordingly: say the photo was - taken when `take_photo` returns ok, but say a move/water proposal needs approval. -- When a question depends on the live garden state, do not rely on a single tool - result. Gather and cross-check evidence across multiple tools and reason about - the combined picture. For example: - - If an image is dark or `segment_image` shows nothing, call `take_photo` for - a fresh frame and/or `get_position` to see where the camera is. - - Combine `get_position`, `list_zones`, and `get_garden` to know which zone the - camera is pointing at and whether the view matches expectations. - - Use `segment_image` when you need numeric presence/absence of classes. - - If the evidence is still unclear after a few tool calls, say so and propose - a concrete next step (e.g. move to a zone with better lighting). -- Use the reasoning/thinking space to plan your tool calls before giving the - final answer; the user will see the reasoning as a collapsible pill. -""" - - -def build_chat_system_prompt( - action_vocabulary: list[str], *, propose_only: bool = False -) -> str: - prompt = CHAT_SYSTEM_PROMPT - if propose_only: - prompt += ( - "\n\nIMPORTANT: You are in proposal mode. When the user asks you to " - "perform one or more actions (move, water, take_photo, etc.), you " - "MUST call the corresponding action tool to register the proposal. " - "The tool will return a proposed-action marker; do NOT describe the " - "action in text without calling the tool first. State the proposal " - "briefly, note that it requires approval, and stop. Do NOT ask the " - "user a yes/no approval question and do NOT say the action is done — " - "the interface shows Approve/Reject buttons." - ) - return ( - prompt - + "\n\nRegistered action kinds you can use: " - + ", ".join(sorted(action_vocabulary)) - + "." - ) - - def build_user_prompt( request: str, *, diff --git a/services/planning_service/planning_service/tools.py b/services/planning_service/planning_service/tools.py index cb2b549..623b278 100644 --- a/services/planning_service/planning_service/tools.py +++ b/services/planning_service/planning_service/tools.py @@ -16,7 +16,6 @@ from twfarmbot_core.actions import ActionRegistry - # ── Tool argument schemas ──────────────────────────────────────────────── @@ -67,88 +66,13 @@ class MountToolArgs(BaseModel): def build_tools(registry: ActionRegistry) -> list[BaseTool]: """Build LangChain tool objects for every registered action kind. - Each tool's body is a no-op: invoking it is not how the planner - executes — it just gives the model a structured schema to fill in. - The actual execution happens via :class:`ActionRegistry.dispatch` - in the API layer. + This is now a thin compatibility wrapper around the harness + ``ToolRegistry``. The returned tools carry the correct schemas and + descriptions; execution semantics are applied later by ``AgentLoop``. """ - kinds = set(registry.kinds()) - tools: list[BaseTool] = [] - - if "move" in kinds: - @tool(args_schema=MoveArgs) - def move(x: float, y: float, z: float) -> dict[str, Any]: - """Move the gantry to absolute coordinates (mm).""" - return {"kind": "move", "params": {"x": x, "y": y, "z": z}} - tools.append(move) - - if "water" in kinds: - @tool(args_schema=WaterArgs) - def water(seconds: float) -> dict[str, Any]: - """Turn the pump on for the given seconds.""" - return {"kind": "water", "params": {"seconds": seconds}} - tools.append(water) - - if "find_home" in kinds: - @tool(args_schema=FindHomeArgs) - def find_home(axis: str = "all", speed: int = 100) -> dict[str, Any]: - """Run the end-stop homing sequence to calibrate axes.""" - return {"kind": "find_home", "params": {"axis": axis, "speed": speed}} - tools.append(find_home) - - if "read_pin" in kinds: - @tool(args_schema=ReadPinArgs) - def read_pin(pin: int, mode: str = "digital") -> dict[str, Any]: - """Read a GPIO pin value.""" - return {"kind": "read_pin", "params": {"pin": pin, "mode": mode}} - tools.append(read_pin) - - if "write_pin" in kinds: - @tool(args_schema=WritePinArgs) - def write_pin(pin: int, value: int, mode: str = "digital") -> dict[str, Any]: - """Write a GPIO pin to the given value (0 or 1).""" - return {"kind": "write_pin", "params": {"pin": pin, "value": value, "mode": mode}} - tools.append(write_pin) - - if "take_photo" in kinds: - @tool - def take_photo() -> dict[str, Any]: - """Trigger the camera to take a photo.""" - return {"kind": "take_photo", "params": {}} - tools.append(take_photo) - - if "send_message" in kinds: - @tool(args_schema=SendMessageArgs) - def send_message(message: str, message_type: str = "info") -> dict[str, Any]: - """Show a message to the user.""" - return { - "kind": "send_message", - "params": {"message": message, "message_type": message_type}, - } - tools.append(send_message) - - if "mount_tool" in kinds: - @tool(args_schema=MountToolArgs) - def mount_tool(tool_name: str) -> dict[str, Any]: - """Mount a named tool on the gantry.""" - return {"kind": "mount_tool", "params": {"tool_name": tool_name}} - tools.append(mount_tool) - - if "dismount_tool" in kinds: - @tool - def dismount_tool() -> dict[str, Any]: - """Dismount whatever tool is currently mounted.""" - return {"kind": "dismount_tool", "params": {}} - tools.append(dismount_tool) - - if "e_stop" in kinds: - @tool - def e_stop() -> dict[str, Any]: - """Emergency stop — halt the robot immediately.""" - return {"kind": "e_stop", "params": {}} - tools.append(e_stop) - - return tools + from .harness import ToolRegistry + + return ToolRegistry(registry, system_state=None).langchain_tools() def extract_tool_calls(response: Any) -> list[dict[str, Any]] | None: @@ -163,8 +87,12 @@ def extract_tool_calls(response: Any) -> list[dict[str, Any]] | None: return None out: list[dict[str, Any]] = [] for call in tool_calls: - name = call.get("name") if isinstance(call, dict) else getattr(call, "name", None) - args = call.get("args") if isinstance(call, dict) else getattr(call, "args", None) + name = ( + call.get("name") if isinstance(call, dict) else getattr(call, "name", None) + ) + args = ( + call.get("args") if isinstance(call, dict) else getattr(call, "args", None) + ) if name is None: continue out.append({"name": name, "args": dict(args or {})}) @@ -182,8 +110,17 @@ def tool_calls_to_actions( for call in tool_calls: name = call["name"] args = call.get("args", {}) - if name in {"move", "water", "find_home", "read_pin", "write_pin", - "take_photo", "send_message", "mount_tool", - "dismount_tool", "e_stop"}: + if name in { + "move", + "water", + "find_home", + "read_pin", + "write_pin", + "take_photo", + "send_message", + "mount_tool", + "dismount_tool", + "e_stop", + }: out.append((name, args)) return out diff --git a/services/safety_service/safety_service/__init__.py b/services/safety_service/safety_service/__init__.py index a673596..2c3f61c 100644 --- a/services/safety_service/safety_service/__init__.py +++ b/services/safety_service/safety_service/__init__.py @@ -3,6 +3,9 @@ Per the README: *Any code path that ultimately moves the FarmBot (watering, weeding, tool changes, …) must pass through safety_service before it reaches farmbot_gateway.* + +Validators are registered by action kind. Adding a new safety rule is now a +single line: ``register("my_kind", my_validator)``. """ from __future__ import annotations @@ -10,6 +13,7 @@ import logging import os from dataclasses import dataclass, field +from typing import Callable from twfarmbot_core.domain.action import Action @@ -39,6 +43,18 @@ def load_limits() -> SafetyLimits: ) +Validator = Callable[[Action, SafetyLimits], None] + +_VALIDATORS: dict[str, Validator] = {} + + +def register(kind: str, validator: Validator) -> None: + """Register a safety validator for an action kind.""" + if kind in _VALIDATORS: + raise ValueError(f"safety validator already registered for {kind!r}") + _VALIDATORS[kind] = validator + + def _check_move(action: Action, limits: SafetyLimits) -> None: for axis in ("x", "y", "z"): if axis not in action.params: @@ -56,25 +72,28 @@ def _check_move(action: Action, limits: SafetyLimits) -> None: ) +def _check_water(action: Action, limits: SafetyLimits) -> None: + seconds = float(action.params.get("seconds", 0.0)) + if seconds <= 0: + raise UnsafeActionError(f"water action needs positive seconds, got {seconds}") + if seconds > limits.max_water_seconds: + raise UnsafeActionError( + f"water action exceeds max {limits.max_water_seconds}s (got {seconds}s)" + ) + + +register("move", _check_move) +register("water", _check_water) + + def validate(action: Action, *, limits: SafetyLimits | None = None) -> Action: """Check an Action against the safety rules. Returns it unchanged on pass. Raises :class:`UnsafeActionError` if the action is rejected. """ limits = limits or load_limits() - - if action.kind == "water": - seconds = float(action.params.get("seconds", 0.0)) - if seconds <= 0: - raise UnsafeActionError(f"water action needs positive seconds, got {seconds}") - if seconds > limits.max_water_seconds: - raise UnsafeActionError( - f"water action exceeds max {limits.max_water_seconds}s (got {seconds}s)" - ) - - elif action.kind == "move": - _check_move(action, limits) - + validator = _VALIDATORS.get(action.kind) + if validator is not None: + validator(action, limits) log.info("safety: approved %s", action) return action - diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..5c23f85 --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,256 @@ +"""Tests for the planning_service harness primitives. + +These tests do not need a live LLM or FarmBot; they exercise policy, +approval, reasoning extraction, prompt generation, and the agent loop +with small fake models. +""" + +from __future__ import annotations + +from typing import Any, Sequence + +import pytest +from langchain_core.language_models.fake_chat_models import FakeListChatModel +from langchain_core.messages import AIMessage +from langchain_core.tools import BaseTool + +from planning_service.harness import ( + AgentLoop, + ApprovalGate, + ContextBuilder, + ReasoningController, + ToolCategory, + ToolDescriptor, + ToolPolicy, + ToolRegistry, +) +from twfarmbot_core.actions import ActionRegistry +from twfarmbot_core.domain import Action + + +class _ToolAwareFake(FakeListChatModel): + """Fake model that supports ``bind_tools`` and can emit tool_calls.""" + + _custom_responses: list[Any] + _custom_index: int = 0 + + def bind_tools( + self, + tools: Sequence[BaseTool], + **kwargs: Any, + ) -> "_ToolAwareFake": + return self + + def set_responses(self, responses: list[Any]) -> None: + """Provide a sequence of AIMessage or string responses.""" + self._custom_responses = responses + self._custom_index = 0 + + def invoke(self, *_args: Any, **_kwargs: Any) -> AIMessage: + if not getattr(self, "_custom_responses", None): + return super().invoke(*_args, **_kwargs) + response = self._custom_responses[self._custom_index] + self._custom_index = min( + self._custom_index + 1, len(self._custom_responses) - 1 + ) + if isinstance(response, AIMessage): + return response + return AIMessage(content=str(response)) + + def stream(self, *_args: Any, **_kwargs: Any): + text = self.invoke(*_args, **_kwargs).content + yield type("Chunk", (), {"content": text, "additional_kwargs": {}})() + + +def _make_registry() -> ActionRegistry: + reg = ActionRegistry() + reg.register("move", lambda a: a) + reg.register("water", lambda a: a) + reg.register("take_photo", lambda a: a) + reg.register("e_stop", lambda a: a) + return reg + + +# ───────────────────────────── ToolPolicy / Registry ───────────────────────── + + +def test_tool_registry_contains_all_action_kinds() -> None: + reg = _make_registry() + tool_registry = ToolRegistry(reg) + names = {d.name for d in tool_registry.descriptors()} + assert names >= {"move", "water", "take_photo", "e_stop"} + + +def test_action_policies_are_categorized() -> None: + reg = _make_registry() + tool_registry = ToolRegistry(reg) + by_name = tool_registry.by_name() + assert by_name["move"].policy.category == ToolCategory.ACT + assert by_name["move"].policy.requires_approval is True + assert by_name["take_photo"].policy.category == ToolCategory.READ + assert by_name["take_photo"].policy.requires_approval is False + assert by_name["e_stop"].policy.category == ToolCategory.ACT + assert by_name["e_stop"].policy.requires_approval is False + + +# ───────────────────────────────── ApprovalGate ────────────────────────────── + + +def test_approval_gate_proposes_dangerous_actions_in_chat() -> None: + reg = _make_registry() + gate = ApprovalGate(reg) + descriptor = ToolRegistry(reg).by_name()["move"] + result = gate.resolve( + descriptor, {"x": 0, "y": 0, "z": 0}, propose_only=True, allow_actions=True + ) + assert result.status == "proposed" + + +def test_approval_gate_executes_safe_read_actions() -> None: + reg = _make_registry() + gate = ApprovalGate(reg) + descriptor = ToolRegistry(reg).by_name()["take_photo"] + result = gate.resolve(descriptor, {}, propose_only=True, allow_actions=True) + assert result.status == "ok" + + +def test_approval_gate_never_executes_in_planning_mode() -> None: + reg = _make_registry() + gate = ApprovalGate(reg, planning_mode=True) + descriptor = ToolRegistry(reg).by_name()["e_stop"] + result = gate.resolve(descriptor, {}, propose_only=False, allow_actions=True) + assert result.status == "proposed" + + +# ───────────────────────────── ReasoningController ─────────────────────────── + + +def test_reasoning_controller_extracts_think_tags() -> None: + rc = ReasoningController() + thinking = rc.extract( + type("Msg", (), {"content": "before step 1 after"})() + ) + assert thinking == "step 1" + assert rc.strip_from_text("before x after") == "before after" + + +def test_reasoning_controller_extracts_provider_reasoning() -> None: + rc = ReasoningController() + msg = type( + "Msg", + (), + { + "content": "answer", + "response_metadata": {}, + "additional_kwargs": {"reasoning": "step by step"}, + }, + )() + assert rc.extract(msg) == "step by step" + + +def test_reasoning_controller_splits_text_events() -> None: + rc = ReasoningController() + events = list(rc.split_text("hi reason bye")) + assert events == [ + {"type": "delta", "content": "hi "}, + {"type": "thinking", "content": "reason"}, + {"type": "delta", "content": " bye"}, + ] + + +# ─────────────────────────────── ContextBuilder ────────────────────────────── + + +def test_context_builder_lists_tools_in_prompt() -> None: + reg = _make_registry() + tool_registry = ToolRegistry(reg) + builder = ContextBuilder(tool_registry) + prompt = builder.chat_system_prompt() + assert "Read-only tools" in prompt + assert "Execution tools" in prompt + assert "take_photo" in prompt + assert "move" in prompt + + +# ────────────────────────────────── AgentLoop ──────────────────────────────── + + +def _make_loop( + model: _ToolAwareFake, + reg: ActionRegistry, + propose_only: bool = True, +) -> AgentLoop: + tool_registry = ToolRegistry(reg) + approval_gate = ApprovalGate(reg) + builder = ContextBuilder(tool_registry) + bound = model.bind_tools(tool_registry.langchain_tools()) + return AgentLoop( + model=bound, + tool_registry=tool_registry, + approval_gate=approval_gate, + context_builder=builder, + propose_only=propose_only, + allow_actions=True, + max_iterations=3, + ) + + +def test_agent_loop_runs_multiple_introspection_turns() -> None: + reg = _make_registry() + # First turn calls get_position; second turn answers. + fake = _ToolAwareFake(responses=["unused"]) + fake.set_responses( + [ + AIMessage( + content="", + tool_calls=[{"name": "get_position", "id": "1", "args": {}}], + ), + "done", + ] + ) + + loop = _make_loop(fake, reg) + result = loop.run([{"role": "user", "content": "where am I"}]) + assert result.response == "done" + assert any(tc["name"] == "get_position" for tc in result.tool_calls) + + +def test_agent_loop_proposes_move_without_executing() -> None: + reg = _make_registry() + fake = _ToolAwareFake(responses=["unused"]) + fake.set_responses( + [ + AIMessage( + content="", + tool_calls=[ + {"name": "move", "id": "1", "args": {"x": 100, "y": 200, "z": 0}} + ], + ), + "proposed", + ] + ) + loop = _make_loop(fake, reg, propose_only=True) + result = loop.run([{"role": "user", "content": "move to 100,200"}]) + assert len(result.proposed_actions) == 1 + assert result.proposed_actions[0]["kind"] == "move" + assert result.response == "proposed" + + +def test_agent_loop_streams_tool_call_and_delta_events() -> None: + reg = _make_registry() + fake = _ToolAwareFake(responses=["unused"]) + fake.set_responses( + [ + AIMessage( + content="", + tool_calls=[{"name": "take_photo", "id": "1", "args": {}}], + ), + "photo taken", + ] + ) + loop = _make_loop(fake, reg) + events = list(loop.stream([{"role": "user", "content": "take a photo"}])) + types = [e["type"] for e in events] + assert "tool_call" in types + assert "meta" in types + assert "delta" in types diff --git a/tests/test_planning_prompts.py b/tests/test_planning_prompts.py index 9b4c33c..9cce64b 100644 --- a/tests/test_planning_prompts.py +++ b/tests/test_planning_prompts.py @@ -1,15 +1,33 @@ -"""Tests for the planner prompt builders.""" +"""Tests for the planner/chat prompt builders.""" from __future__ import annotations -from planning_service.prompt import build_system_prompt, build_user_prompt +from planning_service.harness import ContextBuilder, ToolRegistry +from planning_service.prompt import build_user_prompt +from twfarmbot_core.actions import ActionRegistry -def test_system_prompt_mentions_move_vs_find_home() -> None: - prompt = build_system_prompt(["move", "find_home", "water"]) - assert "move(x=0, y=0, z=0)" in prompt +def _make_registry() -> ActionRegistry: + reg = ActionRegistry() + reg.register("move", lambda a: a) + reg.register("find_home", lambda a: a) + reg.register("water", lambda a: a) + return reg + + +def test_system_prompt_mentions_action_kinds() -> None: + reg = _make_registry() + prompt = ContextBuilder(ToolRegistry(reg)).planner_system_prompt() + assert "move" in prompt assert "find_home" in prompt - assert "end-stop homing" in prompt or "physical limits" in prompt + assert "water" in prompt + + +def test_chat_prompt_lists_execution_tools() -> None: + reg = _make_registry() + prompt = ContextBuilder(ToolRegistry(reg)).chat_system_prompt() + assert "Execution tools" in prompt + assert "move" in prompt def test_user_prompt_includes_world_context() -> None: From f2bcc0f088f2acb03eebe27530e42db24e4f9f82 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Wed, 24 Jun 2026 08:52:48 +0200 Subject: [PATCH 02/10] added wandb weave tracking --- configs/dev.yaml | 3 + .../planning_service/__init__.py | 1 + .../planning_service/agent.py | 2 + .../planning_service/planning_service/chat.py | 1 + .../planning_service/config.py | 14 + .../planning_service/harness/agent_loop.py | 18 +- .../planning_service/harness/tracing.py | 155 +++ services/planning_service/pyproject.toml | 2 + uv.lock | 932 +++++++++++++++++- 9 files changed, 1113 insertions(+), 15 deletions(-) create mode 100644 services/planning_service/planning_service/harness/tracing.py diff --git a/configs/dev.yaml b/configs/dev.yaml index f0cbdaf..71737de 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -15,6 +15,9 @@ planning: # OpenRouter use: extra_body: {reasoning: {effort: low}} # For native DeepSeek use: extra_body: {thinking: {type: enabled}} extra_body: {reasoning: {effort: low}} + # Optional W&B Weave project for tracing. Set WEAVE_PROJECT or this field + # to enable model/tool-call/token tracing. + weave_project: "" # FarmBot WiFi credentials are read from environment variables # (FARMBOT_HOST, FARMBOT_EMAIL, FARMBOT_PASSWORD, optional FARMBOT_TOKEN) diff --git a/services/planning_service/planning_service/__init__.py b/services/planning_service/planning_service/__init__.py index b7a3e00..ab27b68 100644 --- a/services/planning_service/planning_service/__init__.py +++ b/services/planning_service/planning_service/__init__.py @@ -91,6 +91,7 @@ def plan( approval_gate=approval_gate, context_builder=context_builder, reasoning=ReasoningController(), + model_name=cfg.model, propose_only=False, allow_actions=False, max_iterations=3, diff --git a/services/planning_service/planning_service/agent.py b/services/planning_service/planning_service/agent.py index 436fe7e..b401f36 100644 --- a/services/planning_service/planning_service/agent.py +++ b/services/planning_service/planning_service/agent.py @@ -11,6 +11,7 @@ from .client import build_chat_model from .config import PlannerConfig, load_config from .harness import ToolRegistry +from .harness.tracing import init_weave from .introspection import SystemStateProvider @@ -20,6 +21,7 @@ def build_base_model( ) -> tuple[PlannerConfig, BaseChatModel]: """Resolve config and build the chat model.""" cfg = config or load_config() + init_weave(cfg.weave_project) base_model = model or build_chat_model( base_url=cfg.base_url, model=cfg.model, diff --git a/services/planning_service/planning_service/chat.py b/services/planning_service/planning_service/chat.py index ff1b196..9bd5a76 100644 --- a/services/planning_service/planning_service/chat.py +++ b/services/planning_service/planning_service/chat.py @@ -64,6 +64,7 @@ def _make_loop( approval_gate=approval_gate, context_builder=context_builder, reasoning=ReasoningController(), + model_name=cfg.model, propose_only=propose_only, allow_actions=allow_actions, max_iterations=max_iterations, diff --git a/services/planning_service/planning_service/config.py b/services/planning_service/planning_service/config.py index 2dc05a3..56fa380 100644 --- a/services/planning_service/planning_service/config.py +++ b/services/planning_service/planning_service/config.py @@ -21,6 +21,17 @@ from pathlib import Path from typing import Any, Mapping +try: + from dotenv import load_dotenv + + # Load .env once at import time, but never override existing env vars + # so that explicitly exported variables still win. Skip during pytest to + # avoid triggering external services (e.g. Weave) from the test suite. + if not os.environ.get("PYTEST_CURRENT_TEST"): + load_dotenv(override=False) +except ImportError: # pragma: no cover - python-dotenv is optional + pass + from twfarmbot_core.config import load_yaml_config @@ -32,6 +43,7 @@ class PlannerConfig: timeout_s: float temperature: float extra_body: dict[str, Any] | None = None + weave_project: str | None = None DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" @@ -77,6 +89,7 @@ def load_config( extra_body = planning.get("extra_body") if extra_body is not None and not isinstance(extra_body, dict): extra_body = None + weave_project = os.getenv("WEAVE_PROJECT") or planning.get("weave_project") return PlannerConfig( base_url=base_url, model=model, @@ -84,6 +97,7 @@ def load_config( timeout_s=timeout_s, temperature=temperature, extra_body=extra_body, + weave_project=weave_project, ) diff --git a/services/planning_service/planning_service/harness/agent_loop.py b/services/planning_service/planning_service/harness/agent_loop.py index 08f6f0e..008e0e5 100644 --- a/services/planning_service/planning_service/harness/agent_loop.py +++ b/services/planning_service/planning_service/harness/agent_loop.py @@ -23,6 +23,7 @@ from .reasoning_controller import ReasoningController from .tool_policy import ToolCategory, ToolDescriptor from .tool_registry import ToolRegistry +from .tracing import is_enabled, timed_invoke, timed_stream, trace_tool_call def _llm_friendly_result(result: Any) -> Any: @@ -55,6 +56,7 @@ def __init__( context_builder: ContextBuilder, reasoning: ReasoningController | None = None, *, + model_name: str = "unknown", propose_only: bool = False, allow_actions: bool = True, max_iterations: int = 5, @@ -65,6 +67,7 @@ def __init__( self._approval_gate = approval_gate self._context_builder = context_builder self._reasoning = reasoning or ReasoningController() + self._model_name = model_name self._propose_only = propose_only self._allow_actions = allow_actions self._max_iterations = max_iterations @@ -88,7 +91,7 @@ def run(self, messages: list[dict[str, Any]]) -> AgentTurnResult: final_thinking: str | None = None for _ in range(self._max_iterations): - response = self._model.invoke(lc_messages) + response = timed_invoke(self._model, lc_messages, self._model_name) last_response = response tool_calls = getattr(response, "tool_calls", None) or [] if not tool_calls: @@ -144,7 +147,7 @@ def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: last_response: Any = None for _ in range(self._max_iterations): - response = self._model.invoke(lc_messages) + response = timed_invoke(self._model, lc_messages, self._model_name) last_response = response tool_calls = getattr(response, "tool_calls", None) or [] if not tool_calls: @@ -187,7 +190,7 @@ def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: buffer = "" streamed_reasoning: list[str] = [] streamed_reasoning_emitted = bool(tool_turn_thinking) - for chunk in self._model.stream(lc_messages): + for chunk in timed_stream(self._model, lc_messages, self._model_name): for event in self._reasoning.stream_chunks( chunk, accumulated_reasoning=streamed_reasoning, @@ -228,7 +231,7 @@ def plan_request(self, request: str, *, max_iterations: int = 3) -> AgentTurnRes final_thinking: str | None = None for _ in range(max_iterations): - response = self._model.invoke(lc_messages) + response = timed_invoke(self._model, lc_messages, self._model_name) last_response = response tool_calls = getattr(response, "tool_calls", None) or [] if not tool_calls: @@ -317,6 +320,9 @@ def _invoke_tool( if tool is None: return {"error": f"unknown tool {name!r}"} try: - return tool.invoke(args) + result = tool.invoke(args) except Exception as err: # noqa: BLE001 - return {"error": f"{type(err).__name__}: {err}"} + result = {"error": f"{type(err).__name__}: {err}"} + if is_enabled(): + trace_tool_call(name, args, result) + return result diff --git a/services/planning_service/planning_service/harness/tracing.py b/services/planning_service/planning_service/harness/tracing.py new file mode 100644 index 0000000..16fc781 --- /dev/null +++ b/services/planning_service/planning_service/harness/tracing.py @@ -0,0 +1,155 @@ +"""Optional W&B Weave tracing for the planning harness. + +Tracing is enabled by setting ``WEAVE_PROJECT`` or the YAML +``planning.weave_project`` value. When enabled, every model call and tool +invocation is logged to Weave, including token usage, latency, and +reasoning traces. +""" + +from __future__ import annotations + +import logging +import os +import time +from typing import Any, Iterator + +import weave + +_log = logging.getLogger(__name__) + +_weave_initialized: bool = False + + +def init_weave(project_name: str | None) -> None: + """Initialize the Weave client once. + + No-op if ``project_name`` is empty, Weave is already initialized, or + the code is running inside pytest. + """ + global _weave_initialized + if _weave_initialized or not project_name: + return + if os.environ.get("PYTEST_CURRENT_TEST"): + return + try: + weave.init(project_name) + _weave_initialized = True + _log.info("weave tracing initialized for project %s", project_name) + except Exception as err: # noqa: BLE001 + _log.warning("weave initialization failed: %s", err) + + +def is_enabled() -> bool: + return _weave_initialized + + +def langchain_tracer() -> Any | None: + """Return a fresh LangChain WeaveTracer, or None if Weave is off.""" + if not _weave_initialized: + return None + try: + from weave.integrations.langchain.langchain import WeaveTracer + + return WeaveTracer() + except Exception as err: # noqa: BLE001 + _log.warning("could not create WeaveTracer: %s", err) + return None + + +@weave.op() # type: ignore[misc] +def trace_tool_call( + name: str, args: dict[str, Any], result: dict[str, Any] +) -> dict[str, Any]: + """Trace a single tool invocation. + + This is a no-op at runtime unless Weave has been initialized; the + decorator simply records inputs/outputs when tracing is active. + """ + return {"name": name, "args": args, "result": result} + + +@weave.op() # type: ignore[misc] +def trace_model_invoke(response: Any, *, latency_s: float, model: str) -> Any: + """Trace a non-streaming model response. + + We attach token usage and latency as attributes when available. + """ + usage = _extract_usage(response) + attrs: dict[str, Any] = {"model": model, "latency_s": latency_s} + if usage: + attrs.update(usage) + with weave.attributes(attrs): + return response + + +@weave.op() # type: ignore[misc] +def trace_model_stream(chunks: list[Any], *, latency_s: float, model: str) -> list[Any]: + """Trace a streaming model response. + + Chunks are collected before this op is called so the whole stream is + captured as a single trace with aggregated metadata. + """ + usage = _extract_usage(chunks[-1]) if chunks else None + attrs: dict[str, Any] = {"model": model, "latency_s": latency_s, "stream": True} + if usage: + attrs.update(usage) + with weave.attributes(attrs): + return chunks + + +def _extract_usage(response: Any) -> dict[str, int] | None: + """Best-effort extraction of token usage from a LangChain response.""" + if response is None: + return None + metadata = getattr(response, "response_metadata", {}) or {} + token_usage = metadata.get("token_usage") or metadata.get("usage") + if isinstance(token_usage, dict): + out: dict[str, int] = {} + for key in ("prompt_tokens", "completion_tokens", "total_tokens"): + value = token_usage.get(key) + if isinstance(value, (int, float)): + out[key] = int(value) + return out or None + usage_metadata = getattr(response, "usage_metadata", None) + if isinstance(usage_metadata, dict): + out = {} + for key in ("input_tokens", "output_tokens", "total_tokens"): + value = usage_metadata.get(key) + if isinstance(value, (int, float)): + out[key] = int(value) + return out or None + return None + + +def timed_invoke(model: Any, messages: Any, model_name: str) -> Any: + """Invoke a model and trace it, returning the response.""" + start = time.perf_counter() + tracer = langchain_tracer() + kwargs: dict[str, Any] = {"config": {"callbacks": [tracer]}} if tracer else {} + try: + response = model.invoke(messages, **kwargs) + except Exception: + _log.exception("model invoke failed") + raise + latency = time.perf_counter() - start + if is_enabled(): + trace_model_invoke(response, latency_s=latency, model=model_name) + return response + + +def timed_stream(model: Any, messages: Any, model_name: str) -> Iterator[Any]: + """Stream a model response and trace the aggregated result.""" + start = time.perf_counter() + tracer = langchain_tracer() + kwargs: dict[str, Any] = {"config": {"callbacks": [tracer]}} if tracer else {} + chunks: list[Any] = [] + try: + for chunk in model.stream(messages, **kwargs): + chunks.append(chunk) + yield chunk + except Exception: + _log.exception("model stream failed") + raise + latency = time.perf_counter() - start + if is_enabled(): + trace_model_stream(chunks, latency_s=latency, model=model_name) diff --git a/services/planning_service/pyproject.toml b/services/planning_service/pyproject.toml index 877de61..f914a07 100644 --- a/services/planning_service/pyproject.toml +++ b/services/planning_service/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "pydantic>=2", "pyyaml>=6", "httpx>=0.27", + "weave>=0.50", + "python-dotenv>=1", ] [build-system] diff --git a/uv.lock b/uv.lock index e82c8e2..52bb794 100644 --- a/uv.lock +++ b/uv.lock @@ -30,6 +30,18 @@ members = [ "twfarmbot-worker", ] +[[package]] +name = "abnf" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/f2/7b5fac50ee42e8b8d4a098d76743a394546f938c94125adbb93414e5ae7d/abnf-2.2.0.tar.gz", hash = "sha256:433380fd32855bbc60bc7b3d35d40616e21383a32ed1c9b8893d16d9f4a6c2f4", size = 197507, upload-time = "2023-03-17T18:26:24.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/95/f456ae7928a2f3a913f467d4fd9e662e295dd7349fc58b35f77f6c757a23/abnf-2.2.0-py3-none-any.whl", hash = "sha256:5dc2ae31a84ff454f7de46e08a2a21a442a0e21a092468420587a1590b490d1f", size = 39938, upload-time = "2023-03-17T18:26:22.608Z" }, +] + [[package]] name = "altair" version = "6.2.1" @@ -99,6 +111,15 @@ wheels = [ { 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]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -126,6 +147,131 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/1b/7f73766c119a1344eb69e31890ede7c5825ce03d69a9d29292d1bd1cfa1b/chardet-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c79b13c9908ac7dfe0a74116ebc9a0f28b2319d23c32f3dfcdfbe1279c7eaf", size = 874121, upload-time = "2026-04-13T21:32:47.065Z" }, + { url = "https://files.pythonhosted.org/packages/8b/02/b677c8203d34dad6c2af48287bb1f8c5dff63db2094636fbe634b555b7fb/chardet-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bba8bea1b28d927b3e99e47deafe53658d34497c0a891d95ff1ba8ff6663f01c", size = 856900, upload-time = "2026-04-13T21:32:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/c4/4b/1361a485a999d97cac4c895e615326f69a639532a52ef365a468bd09bad1/chardet-7.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23163921dccf3103ce59540b0443c106d2c0a0ff2e0503e05196f5e6fdea453f", size = 876634, upload-time = "2026-04-13T21:32:50.238Z" }, + { url = "https://files.pythonhosted.org/packages/87/23/e31c8ad33aa448f0845fd58af5fc22da1626407616d09df4973b2b34f477/chardet-7.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfb54563fe5f130da17c44c6a4e2e8052ba628e5ab4eab7ef8190f736f0f8f72", size = 886497, upload-time = "2026-04-13T21:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/18/ef/ea4edec8c87f7e6eda02673acc68fe48725e564fc5a1865782efb53d5598/chardet-7.4.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3990fffcc6a6045f2234ab72752ad037e3b2d48c72037f244d42738db397eb75", size = 881061, upload-time = "2026-04-13T21:32:53.755Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/fc10600da98541777d720ad9e6bc040c0e0af1adb92e27142e35158957cb/chardet-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c7116b0452994734ccff35e154b44240090eb0f4f74b9106292668133557c175", size = 942533, upload-time = "2026-04-13T21:32:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -231,6 +377,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "cint" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/c8/3ae22fa142be0bf9eee856e90c314f4144dfae376cc5e3e55b9a169670fb/cint-1.0.0.tar.gz", hash = "sha256:66f026d28c46ef9ea9635be5cb342506c6a1af80d11cb1c881a8898ca429fc91", size = 4641, upload-time = "2019-03-19T01:07:48.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/c2/898e59963084e1e2cbd4aad1dee92c5bd7a79d121dcff1e659c2a0c2174e/cint-1.0.0-py3-none-any.whl", hash = "sha256:8aa33028e04015711c0305f918cb278f1dc8c5c9997acdc45efad2c7cb1abf50", size = 5573, upload-time = "2019-03-19T01:07:46.496Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -252,6 +407,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -261,6 +473,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "diskcache-weave" +version = "5.6.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/52/634e1f43486489fdaded1a7c9bd3524b7e0ca9bcc43af426afa511c541e2/diskcache_weave-5.6.3.post1.tar.gz", hash = "sha256:1fe7e648d1d85d517c05b296f1692e7c425a71714dc31a4b7a584a8f8f5604f2", size = 68297, upload-time = "2026-03-19T14:57:54.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/8d/92887441bc338fb8d0b8ea75eb0392c00e20a85ec0bbe02f273188849568/diskcache_weave-5.6.3.post1-py3-none-any.whl", hash = "sha256:b00e9842b74eeecf314456f9c833a6d4f7792ed12b20297b4d3b9df7859ee66f", size = 45905, upload-time = "2026-03-19T14:57:52.819Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -322,6 +543,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/380b9a5922f4340e51c309cde09e5bd32e62f02302971bee30dc15aa0624/fastapi-0.137.1-py3-none-any.whl", hash = "sha256:64f6983c59e45c4b9fdc44e57cb8035c2451ee91ea8e8ec042aca37de7cf6b69", size = 121877, upload-time = "2026-06-15T11:28:19.523Z" }, ] +[[package]] +name = "fickling" +version = "0.1.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/0aea3be8edd5a8de3c1491a12f0149f6db5afd9467dfddaa5ed24a27bef9/fickling-0.1.11.tar.gz", hash = "sha256:3ca0dcc69967c53868b35787017d4d7d8943f096450431f7e3b3a9aadb02b0f5", size = 357476, upload-time = "2026-05-06T15:04:21.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/3b/45b8233feb53dd9da16208b039507604844a07c8b5bb3c5a4e39c520f32d/fickling-0.1.11-py3-none-any.whl", hash = "sha256:19ecb791d781d475e84ed951dc2c4a0c852108e237416d517ab0a8dd771d4098", size = 58549, upload-time = "2026-05-06T15:04:20.168Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -364,6 +594,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[[package]] +name = "gql" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "graphql-core" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/9f/cf224a88ed71eb223b7aa0b9ff0aa10d7ecc9a4acdca2279eb046c26d5dc/gql-4.0.0.tar.gz", hash = "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", size = 215644, upload-time = "2025-08-17T14:32:35.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/94/30bbd09e8d45339fa77a48f5778d74d47e9242c11b3cd1093b3d994770a5/gql-4.0.0-py3-none-any.whl", hash = "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479", size = 89900, upload-time = "2025-08-17T14:32:34.029Z" }, +] + +[package.optional-dependencies] +httpx = [ + { name = "httpx" }, +] + [[package]] name = "gradio-client" version = "2.5.0" @@ -380,6 +642,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/81/0a861b8e1ff42960139c6cd4c7dd591292fa09ea1ae2d87677441cba4c00/gradio_client-2.5.0-py3-none-any.whl", hash = "sha256:d43e2179c29076292a76485ad7ed2e6eaa19d14ac58283bd7f5beabfe4ca958c", size = 59952, upload-time = "2026-04-20T23:16:20.186Z" }, ] +[[package]] +name = "graphql-core" +version = "3.2.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -538,6 +818,18 @@ 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 = "intervaltree" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/c3/b2afa612aa0373f3e6bb190e6de35f293b307d1537f109e3e25dbfcdf212/intervaltree-3.2.1.tar.gz", hash = "sha256:f3f7e8baeb7dd75b9f7a6d33cf3ec10025984a8e66e3016d537e52130c73cfe2", size = 1231531, upload-time = "2025-12-24T04:25:06.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7f/8a80a1c7c2ed05822b5a2b312d2995f30c533641f8198366ba2e26a7bb03/intervaltree-3.2.1-py2.py3-none-any.whl", hash = "sha256:a8a8381bbd35d48ceebee932c77ffc988492d22fb1d27d0ba1d74a7694eb8f0b", size = 25929, upload-time = "2025-12-24T04:25:05.298Z" }, +] + [[package]] name = "isort" version = "8.0.1" @@ -720,6 +1012,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kaitaistruct" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" }, +] + [[package]] name = "langchain-core" version = "1.4.7" @@ -902,6 +1203,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "narwhals" version = "2.22.1" @@ -911,6 +1350,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -1085,6 +1556,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/d2/ba767f4bbb30776c03d40906a2d3afad716a165ffa1771fc23b8992f7920/openai-2.43.0-py3-none-any.whl", hash = "sha256:65a670b54fadf2268c9e1330133373c963eb779ee969e5cbad419ec2c21dce97", size = 1355077, upload-time = "2026-06-17T17:06:53.614Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -1319,6 +1871,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] +[[package]] +name = "pdfminer-six" +version = "20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/a4/5cec1112009f0439a5ca6afa8ace321f0ab2f48da3255b7a1c8953014670/pdfminer_six-20260107.tar.gz", hash = "sha256:96bfd431e3577a55a0efd25676968ca4ce8fd5b53f14565f85716ff363889602", size = 8512094, upload-time = "2026-01-07T13:29:12.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/8b/28c4eaec9d6b036a52cb44720408f26b1a143ca9bce76cc19e8f5de00ab4/pdfminer_six-20260107-py3-none-any.whl", hash = "sha256:366585ba97e80dffa8f00cebe303d2f381884d8637af4ce422f1df3ef38111a9", size = 6592252, upload-time = "2026-01-07T13:29:10.742Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -1435,19 +2000,173 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polyfile-weave" +version = "0.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "abnf" }, + { name = "chardet" }, + { name = "cint" }, + { name = "fickling" }, + { name = "filelock" }, + { name = "graphviz" }, + { name = "intervaltree" }, + { name = "jinja2" }, + { name = "kaitaistruct" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/55/e5400762e3884f743d59291e71eaaa9c52dd7e144b75a11911e74ec1bac9/polyfile_weave-0.5.9.tar.gz", hash = "sha256:12341fab03e06ede1bfebbd3627dd24015fde5353ea74ece2da186321b818bdb", size = 6024974, upload-time = "2026-01-22T22:08:48.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/94/215005530a48c5f7d4ec4a31acdb5828f2bfb985cc6e577b0eaa5882c0e2/polyfile_weave-0.5.9-py3-none-any.whl", hash = "sha256:6ae4b1b5eeac9f5bfc862474484d6d3e33655fab31749d93af0b0a91fddabfc7", size = 1700174, upload-time = "2026-01-22T22:08:46.346Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "protobuf" -version = "7.35.1" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +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/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, + { 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]] @@ -1507,6 +2226,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1680,6 +2408,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, ] +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -1710,6 +2447,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -2250,6 +2996,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/c8/b3c970a5b186722d276cd40a05b3254e03bccc0208560aff20f612e018e8/sentry_sdk-2.63.0.tar.gz", hash = "sha256:2a1502bf864769275dbc8c2c9fc7a0f7f5e18358180b615d262d13a31ffba216", size = 912449, upload-time = "2026-06-16T12:45:57.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/57/cb205f7d93373120f666b9c5736dc0815524d96a9b278e7a728f018dc22a/sentry_sdk-2.63.0-py3-none-any.whl", hash = "sha256:3a9b5ddd403f79eb73bd670f75f04485819db53d28f76ced7bc09041cb0dfd6a", size = 495950, upload-time = "2026-06-16T12:45:55.819Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2286,6 +3045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -2610,10 +3378,12 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-openai" }, { name = "pydantic" }, + { name = "python-dotenv" }, { name = "pyyaml" }, { name = "twfarmbot-core" }, { name = "twfarmbot-ml-utils" }, { name = "twfarmbot-safety-service" }, + { name = "weave" }, ] [package.metadata] @@ -2622,10 +3392,12 @@ requires-dist = [ { name = "langchain-core", specifier = ">=0.3" }, { name = "langchain-openai", specifier = ">=0.2" }, { name = "pydantic", specifier = ">=2" }, + { name = "python-dotenv", specifier = ">=1" }, { name = "pyyaml", specifier = ">=6" }, { name = "twfarmbot-core", editable = "core" }, { name = "twfarmbot-ml-utils", editable = "libs/ml_utils" }, { name = "twfarmbot-safety-service", editable = "services/safety_service" }, + { name = "weave", specifier = ">=0.50" }, ] [[package]] @@ -2889,6 +3661,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "weave" +version = "0.52.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "click" }, + { name = "diskcache-weave" }, + { name = "gql", extra = ["httpx"] }, + { name = "jsonschema" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "polyfile-weave" }, + { name = "pydantic" }, + { name = "sentry-sdk" }, + { name = "tenacity" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/df/dcb32bf366eca5e11d345a8e15238256396a9ff7346a29b3290a358b9cd8/weave-0.52.43.tar.gz", hash = "sha256:3c4abcb4da33e0db8a03bd52739503d99c90bdb1922b704ca2b2df8a0f6d561e", size = 977397, upload-time = "2026-06-15T21:29:23.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/a0/e823f714e337c6342a831307d21d2a12c87bc6a6015fc0ac62be9dd8e01f/weave-0.52.43-py3-none-any.whl", hash = "sha256:d7fb10e893c9da57c7663c8c51a1a67eb59216f9da0e4056a244a41de63faf19", size = 1199217, upload-time = "2026-06-15T21:29:21.267Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -3114,6 +3912,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, ] +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" From fc4b8756b446a9dfd1a54685ed4869dff7d351a5 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Wed, 24 Jun 2026 10:15:18 +0200 Subject: [PATCH 03/10] Implement move_path action with waypoint handling and path planning features --- .../twfarmbot_api_server/handlers/__init__.py | 2 + .../src/twfarmbot_api_server/handlers/path.py | 25 +++ apps/ui/src/twfarmbot_ui/app.py | 14 +- configs/dev.yaml | 174 +----------------- core/twfarmbot_core/actions.py | 7 + .../planning_service/harness/agent_loop.py | 23 ++- .../planning_service/harness/tool_registry.py | 10 + .../planning_service/harness/tracing.py | 5 +- .../planning_service/introspection.py | 72 ++++++++ .../planning_service/path_planning.py | 172 +++++++++++++++++ .../planning_service/tools.py | 22 +++ .../safety_service/safety_service/__init__.py | 24 +++ tests/test_path_planning.py | 120 ++++++++++++ 13 files changed, 493 insertions(+), 177 deletions(-) create mode 100644 apps/api_server/src/twfarmbot_api_server/handlers/path.py create mode 100644 services/planning_service/planning_service/path_planning.py create mode 100644 tests/test_path_planning.py diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py b/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py index 3fae2c4..d359a9a 100644 --- a/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py +++ b/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py @@ -12,6 +12,7 @@ def register_default_handlers(registry: ActionRegistry) -> None: from .watering import handle_water from .move import handle_move + from .path import handle_move_path from .mount_tool import handle_mount_tool, handle_dismount_tool from .pin import handle_read_pin, handle_write_pin from .feedback import handle_send_message, handle_e_stop @@ -20,6 +21,7 @@ def register_default_handlers(registry: ActionRegistry) -> None: registry.register("water", handle_water) registry.register("move", handle_move) + registry.register("move_path", handle_move_path) registry.register("mount_tool", handle_mount_tool) registry.register("dismount_tool", handle_dismount_tool) registry.register("read_pin", handle_read_pin) diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/path.py b/apps/api_server/src/twfarmbot_api_server/handlers/path.py new file mode 100644 index 0000000..32ecdac --- /dev/null +++ b/apps/api_server/src/twfarmbot_api_server/handlers/path.py @@ -0,0 +1,25 @@ +"""Handler for Action(kind='move_path', params={'waypoints': [...], ...}).""" + +from __future__ import annotations + +from twfarmbot_core.domain import Action + +from watering_service.backends import farmbot + + +def handle_move_path(action: Action) -> Action: + waypoints = action.params.get("waypoints", []) + speed = action.params.get("speed") + photo = bool(action.params.get("photo_at_waypoints", False)) + + for wp in waypoints: + x = float(wp["x"]) + y = float(wp["y"]) + z = float(wp["z"]) + farmbot.backend.move( + x, y, z, speed=float(speed) if speed is not None else None + ) + if photo: + farmbot.backend.take_photo() + + return action diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index 2a8e267..a2fe176 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -115,20 +115,20 @@ def _render_tool_call( return if name == "analyze_image" and result.get("image_url"): - st.image(result["image_url"], use_container_width=True) + st.image(result["image_url"], width=400) elif name == "estimate_traversability" and result.get("image_url"): - st.image(result["image_url"], use_container_width=True) + st.image(result["image_url"], width=400) elif name in {"segment_image", "visualize_image_features"} and result.get( "image_urls" ): cols = st.columns(min(len(result["image_urls"]), 3)) for idx, url in enumerate(result["image_urls"]): - cols[idx % len(cols)].image(url, use_container_width=True) + cols[idx % len(cols)].image(url, width=300) for label_text in result.get("labels", []): st.caption(label_text) elif result.get("image_url"): # Fallback for any other tool that returns a single image (e.g. take_photo). - st.image(result["image_url"], use_container_width=True) + st.image(result["image_url"], width=400) def _render_proposed_actions_inline( @@ -1221,7 +1221,7 @@ def _render_camera() -> None: img_col, ctrl_col = st.columns([1.6, 1]) with img_col: - st.image(selected.get("attachment_url"), use_container_width=True) + st.image(selected.get("attachment_url"), width=500) with ctrl_col: st.markdown("**AI analysis**") @@ -1395,7 +1395,7 @@ def _render_camera() -> None: gallery[index % 3].image( image.get("attachment_url"), caption=f"X {image_meta.get('x', '—')} · Y {image_meta.get('y', '—')}", - use_container_width=True, + width=240, ) @@ -1449,7 +1449,7 @@ def _render_chat() -> None: cols = st.columns(min(len(images), 3)) for i, image in enumerate(images): cols[i % len(cols)].image( - image.get("attachment_url"), use_container_width=True + image.get("attachment_url"), width=220 ) proposed_actions = msg.get("proposed_actions", []) diff --git a/configs/dev.yaml b/configs/dev.yaml index 71737de..08b1904 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -83,171 +83,11 @@ spatial: height: 300 metadata: {soil_type: sandy} - entities: - - id: herbs - kind: plant - name: "Herbs" - x: 120 - y: 180 - z: 0 - radius_mm: 80 - metadata: {species: herbs, planted: "2026-05-01"} - - id: seeder - kind: tool - name: "Seeder" - x: 300 + - id: radieschen + kind: radischen + name: "Radischen Zone" + x: 100 y: 400 - z: 0 - radius_mm: 40 - - id: tomato - kind: plant - name: tomato - x: 1150.0 - y: 150.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato - kind: plant - name: tomato - x: 1250.0 - y: 150.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_1 - kind: plant - name: herbs-1 - x: 850.0 - y: 225.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_2 - kind: plant - name: herbs-2 - x: 950.0 - y: 225.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_3 - kind: plant - name: herbs-3 - x: 1050.0 - y: 225.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_4 - kind: plant - name: herbs-4 - x: 1050.0 - y: 175.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_5 - kind: plant - name: herbs-5 - x: 875.0 - y: 175.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_6 - kind: plant - name: herbs-6 - x: 950.0 - y: 175.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_7 - kind: plant - name: herbs-7 - x: 950.0 - y: 125.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_8 - kind: plant - name: herbs-8 - x: 825.0 - y: 125.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: herbs_9 - kind: plant - name: herbs-9 - x: 1025.0 - y: 125.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_1 - kind: plant - name: tomato-1 - x: 1550.0 - y: 275.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_2 - kind: plant - name: tomato-2 - x: 1575.0 - y: 225.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_3 - kind: plant - name: tomato-3 - x: 1575.0 - y: 175.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_4 - kind: plant - name: tomato-4 - x: 1550.0 - y: 125.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_5 - kind: plant - name: tomato-5 - x: 1650.0 - y: 125.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_6 - kind: plant - name: tomato-6 - x: 1650.0 - y: 175.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_7 - kind: plant - name: tomato-7 - x: 1650.0 - y: 225.0 - z: 0.0 - radius_mm: 50 - metadata: {} - - id: tomato_8 - kind: plant - name: tomato-8 - x: 1650.0 - y: 275.0 - z: 0.0 - radius_mm: 50 - metadata: {} + width: 1000 + height: 250 + metadata: {soil_type: sandy} \ No newline at end of file diff --git a/core/twfarmbot_core/actions.py b/core/twfarmbot_core/actions.py index 3dcc437..c9857d8 100644 --- a/core/twfarmbot_core/actions.py +++ b/core/twfarmbot_core/actions.py @@ -39,6 +39,12 @@ def _summarize_move(params: dict[str, Any]) -> str: ) +def _summarize_move_path(params: dict[str, Any]) -> str: + waypoints = params.get("waypoints", []) + photo = params.get("photo_at_waypoints", False) + return f"🛤️ **move_path** ({len(waypoints)} waypoints){' + 📷' if photo else ''}" + + def _summarize_water(params: dict[str, Any]) -> str: return f"🌊 **water** for {_num(params.get('seconds'))} s" @@ -78,6 +84,7 @@ def _summarize_e_stop(params: dict[str, Any]) -> str: ACTION_SUMMARIES: dict[str, Callable[[dict[str, Any]], str]] = { "move": _summarize_move, + "move_path": _summarize_move_path, "water": _summarize_water, "find_home": _summarize_find_home, "take_photo": _summarize_take_photo, diff --git a/services/planning_service/planning_service/harness/agent_loop.py b/services/planning_service/planning_service/harness/agent_loop.py index 008e0e5..2697347 100644 --- a/services/planning_service/planning_service/harness/agent_loop.py +++ b/services/planning_service/planning_service/harness/agent_loop.py @@ -11,12 +11,14 @@ from __future__ import annotations import json +import time from dataclasses import dataclass, field from typing import Any, Iterator from langchain_core.language_models import BaseChatModel from langchain_core.messages import ToolMessage from langchain_core.tools import BaseTool +from pydantic import BaseModel from .approval_gate import ApprovalGate from .context_builder import ContextBuilder @@ -35,6 +37,22 @@ def _llm_friendly_result(result: Any) -> Any: return result +def _normalize_tool_args(value: Any) -> Any: + """Recursively convert Pydantic model instances to plain JSON-serializable dicts. + + LangChain validates tool inputs against Pydantic schemas, so nested args + (e.g. ``Waypoint`` inside ``move_path``) arrive as model instances. The + rest of the harness expects plain dicts. + """ + if isinstance(value, BaseModel): + return _normalize_tool_args(value.model_dump()) + if isinstance(value, list): + return [_normalize_tool_args(item) for item in value] + if isinstance(value, dict): + return {k: _normalize_tool_args(v) for k, v in value.items()} + return value + + @dataclass(frozen=True) class AgentTurnResult: """Result of one agent turn.""" @@ -319,10 +337,13 @@ def _invoke_tool( tool = tool_map.get(name) if tool is None: return {"error": f"unknown tool {name!r}"} + args = _normalize_tool_args(args) + start = time.perf_counter() try: result = tool.invoke(args) except Exception as err: # noqa: BLE001 result = {"error": f"{type(err).__name__}: {err}"} + latency = time.perf_counter() - start if is_enabled(): - trace_tool_call(name, args, result) + trace_tool_call(name, args, _llm_friendly_result(result), latency_s=latency) return result diff --git a/services/planning_service/planning_service/harness/tool_registry.py b/services/planning_service/planning_service/harness/tool_registry.py index 4b03bd4..c3d5c32 100644 --- a/services/planning_service/planning_service/harness/tool_registry.py +++ b/services/planning_service/planning_service/harness/tool_registry.py @@ -14,6 +14,7 @@ FindHomeArgs, MountToolArgs, MoveArgs, + MovePathArgs, ReadPinArgs, SendMessageArgs, WaterArgs, @@ -65,6 +66,12 @@ requires_approval=False, description="Trigger the camera to take a photo.", ), + "move_path": ToolPolicy( + ToolCategory.ACT, + requires_approval=True, + safety_rules=("move_path",), + description="Move the gantry through a sequence of waypoints, optionally taking photos.", + ), "send_message": ToolPolicy( ToolCategory.READ, requires_approval=False, @@ -80,6 +87,7 @@ _ACTION_SCHEMAS: dict[str, type[BaseModel]] = { "move": MoveArgs, + "move_path": MovePathArgs, "water": WaterArgs, "find_home": FindHomeArgs, "read_pin": ReadPinArgs, @@ -100,6 +108,8 @@ "get_pins": ToolCategory.READ, "get_positions": ToolCategory.READ, "get_images": ToolCategory.READ, + "plan_path": ToolCategory.ANALYZE, + "scan_zone": ToolCategory.ANALYZE, "analyze_image": ToolCategory.ANALYZE, "segment_image": ToolCategory.ANALYZE, "visualize_image_features": ToolCategory.ANALYZE, diff --git a/services/planning_service/planning_service/harness/tracing.py b/services/planning_service/planning_service/harness/tracing.py index 16fc781..4783db9 100644 --- a/services/planning_service/planning_service/harness/tracing.py +++ b/services/planning_service/planning_service/harness/tracing.py @@ -58,14 +58,15 @@ def langchain_tracer() -> Any | None: @weave.op() # type: ignore[misc] def trace_tool_call( - name: str, args: dict[str, Any], result: dict[str, Any] + name: str, args: dict[str, Any], result: dict[str, Any], *, latency_s: float ) -> dict[str, Any]: """Trace a single tool invocation. This is a no-op at runtime unless Weave has been initialized; the decorator simply records inputs/outputs when tracing is active. """ - return {"name": name, "args": args, "result": result} + with weave.attributes({"latency_s": latency_s}): + return {"name": name, "args": args, "result": result} @weave.op() # type: ignore[misc] diff --git a/services/planning_service/planning_service/introspection.py b/services/planning_service/planning_service/introspection.py index 8ff0023..6783d2a 100644 --- a/services/planning_service/planning_service/introspection.py +++ b/services/planning_service/planning_service/introspection.py @@ -27,6 +27,8 @@ parse_segmentation_labels, ) +from . import path_planning + log = logging.getLogger(__name__) _IMAGE_PROCESSOR: HuggingFaceImageProcessor | None = None @@ -120,6 +122,37 @@ class EstimateTraversabilityArgs(BaseModel): ) +class PlanPathArgs(BaseModel): + start: dict[str, float] = Field( + description="Starting point as {'x': ..., 'y': ..., 'z': ...}. If z is omitted, the planning z is used." + ) + target: dict[str, float] = Field( + description="Target point as {'x': ..., 'y': ..., 'z': ...}. If z is omitted, the planning z is used." + ) + step_mm: float = Field( + default=100.0, + description="Maximum distance between waypoints in millimetres.", + ) + z: float = Field( + default=0.0, + description="Z coordinate to use for all generated waypoints.", + ) + + +class ScanZoneArgs(BaseModel): + zone_id: str = Field( + description="Zone id or name to scan, e.g. 'tomato' or 'Herbs Zone'." + ) + step_mm: float = Field( + default=200.0, + description="Raster spacing in millimetres.", + ) + z: float = Field( + default=0.0, + description="Z coordinate to use for all generated waypoints.", + ) + + # ── Provider protocol ──────────────────────────────────────────────────── @@ -470,6 +503,43 @@ def estimate_traversability( log.warning("estimate_traversability failed: %s", err) return {"error": f"{type(err).__name__}: {err}"} + @tool(args_schema=PlanPathArgs) + def plan_path( + start: dict[str, float], + target: dict[str, float], + step_mm: float = 100.0, + z: float = 0.0, + ) -> dict[str, Any]: + """Generate a straight-line waypoint path between two points. + + Use this when the model needs to know intermediate waypoints before + issuing a `move_path` action. Returns `{"waypoints": [...], "count": N}`. + """ + try: + waypoints = path_planning.plan_path(start, target, step_mm=step_mm, z=z) + return {"waypoints": waypoints, "count": len(waypoints)} + except Exception as err: # noqa: BLE001 + log.warning("plan_path failed: %s", err) + return {"error": f"{type(err).__name__}: {err}"} + + @tool(args_schema=ScanZoneArgs) + def scan_zone( + zone_id: str, + step_mm: float = 200.0, + z: float = 0.0, + ) -> dict[str, Any]: + """Generate a raster waypoint list covering a configured zone. + + Use this to create a photo/monitoring sweep of a bed. Returns + `{"waypoints": [...], "count": N, "zone_id": ...}`. + """ + try: + waypoints = path_planning.scan_zone(zone_id, step_mm=step_mm, z=z) + return {"waypoints": waypoints, "count": len(waypoints), "zone_id": zone_id} + except Exception as err: # noqa: BLE001 + log.warning("scan_zone failed: %s", err) + return {"error": f"{type(err).__name__}: {err}"} + return [ list_endpoints, get_health, @@ -482,6 +552,8 @@ def estimate_traversability( get_pins, get_positions, get_images, + plan_path, + scan_zone, analyze_image, segment_image, visualize_image_features, diff --git a/services/planning_service/planning_service/path_planning.py b/services/planning_service/planning_service/path_planning.py new file mode 100644 index 0000000..6277fc1 --- /dev/null +++ b/services/planning_service/planning_service/path_planning.py @@ -0,0 +1,172 @@ +"""Spatial path-planning helpers for the FarmBot assistant. + +These functions are pure geometry: they take the configured garden world +(from ``spatial_service``) and produce waypoint lists. They do **not** +mutate the robot. +""" + +from __future__ import annotations + +import math +from typing import Any, Mapping, Sequence + +from spatial_service import load_world +from twfarmbot_core.domain import GardenWorld, GardenZone, Point3D, Rectangle + + +def _point(value: Any) -> Point3D: + """Build a Point3D from a dict, list/tuple, or Point3D.""" + if isinstance(value, Point3D): + return value + if isinstance(value, Mapping): + return Point3D( + x=float(value.get("x", 0)), + y=float(value.get("y", 0)), + z=float(value.get("z", 0)), + ) + if isinstance(value, (list, tuple)) and len(value) >= 2: + return Point3D( + x=float(value[0]), + y=float(value[1]), + z=float(value[2]) if len(value) > 2 else 0.0, + ) + raise ValueError(f"cannot interpret {value!r} as a point") + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(value, high)) + + +def _within_bounds(point: Point3D, bounds: Rectangle) -> bool: + return ( + bounds.x <= point.x <= bounds.x + bounds.width + and bounds.y <= point.y <= bounds.y + bounds.height + ) + + +def plan_path( + start: Any, + target: Any, + step_mm: float = 100.0, + z: float = 0.0, + *, + world: GardenWorld | None = None, +) -> list[dict[str, float]]: + """Generate waypoints along a straight line from ``start`` to ``target``. + + ``step_mm`` is the maximum distance between consecutive waypoints. + The start and target are always included. Waypoints are clamped to the + garden bounds. + """ + if world is None: + world = load_world() + start_pt = _point(start) + target_pt = _point(target) + + # Override Z unless the caller explicitly provided it in start/target. + start_pt = Point3D(start_pt.x, start_pt.y, z) + target_pt = Point3D(target_pt.x, target_pt.y, z) + + dx = target_pt.x - start_pt.x + dy = target_pt.y - start_pt.y + distance = math.hypot(dx, dy) + + step = max(1.0, float(step_mm)) + if distance <= step: + steps = 1 + else: + steps = max(1, math.ceil(distance / step)) + + bounds = world.bounds + waypoints: list[dict[str, float]] = [] + for i in range(steps + 1): + t = i / steps + x = _clamp(start_pt.x + dx * t, bounds.x, bounds.x + bounds.width) + y = _clamp(start_pt.y + dy * t, bounds.y, bounds.y + bounds.height) + waypoints.append({"x": round(x, 2), "y": round(y, 2), "z": z}) + + # Deduplicate start == target case. + if len(waypoints) > 1 and waypoints[0] == waypoints[-1]: + return [waypoints[0]] + return waypoints + + +def scan_zone( + zone_id: str, + step_mm: float = 200.0, + z: float = 0.0, + *, + world: GardenWorld | None = None, +) -> list[dict[str, float]]: + """Generate a raster (boustrophedon) waypoint list covering a zone. + + The zone is scanned in rows along the X axis; each subsequent row is + traversed in the opposite direction to minimise unnecessary travel. + Waypoints are clamped to the garden bounds. + """ + if world is None: + world = load_world() + + zone: GardenZone | None = None + for z_candidate in world.zones: + if z_candidate.id == zone_id or z_candidate.name == zone_id: + zone = z_candidate + break + if zone is None: + raise ValueError(f"zone {zone_id!r} not found") + + step = max(1.0, float(step_mm)) + bounds = world.bounds + b = zone.bounds + + # Snap the scan lines to be centred inside the zone. + y_start = b.y + step / 2 + y_end = b.y + b.height - step / 2 + if y_start > y_end: + y_start = b.y + b.height / 2 + y_end = y_start + + x_start = b.x + step / 2 + x_end = b.x + b.width - step / 2 + if x_start > x_end: + x_start = b.x + b.width / 2 + x_end = x_start + + waypoints: list[dict[str, float]] = [] + reverse = False + y = y_start + while y <= y_end + 1e-6: + y_clamped = _clamp(y, bounds.y, bounds.y + bounds.height) + xs = list(_raster_x_line(x_start, x_end, step, reverse)) + for x in xs: + x_clamped = _clamp(x, bounds.x, bounds.x + bounds.width) + waypoints.append({"x": round(x_clamped, 2), "y": round(y_clamped, 2), "z": z}) + reverse = not reverse + if y >= y_end - 1e-6: + break + y += step + + return waypoints + + +def _raster_x_line(x_start: float, x_end: float, step: float, reverse: bool) -> Sequence[float]: + """Return X coordinates for one raster row.""" + if x_start > x_end: + return [] + if reverse: + pts: list[float] = [] + x = x_end + while x >= x_start - 1e-6: + pts.append(x) + if x <= x_start + 1e-6: + break + x -= step + return pts + pts = [] + x = x_start + while x <= x_end + 1e-6: + pts.append(x) + if x >= x_end - 1e-6: + break + x += step + return pts diff --git a/services/planning_service/planning_service/tools.py b/services/planning_service/planning_service/tools.py index 623b278..c6c39aa 100644 --- a/services/planning_service/planning_service/tools.py +++ b/services/planning_service/planning_service/tools.py @@ -60,6 +60,27 @@ class MountToolArgs(BaseModel): tool_name: str = Field(..., description="Name of the tool to mount.") +class Waypoint(BaseModel): + x: float = Field(..., description="X coordinate in millimetres.") + y: float = Field(..., description="Y coordinate in millimetres.") + z: float = Field(..., description="Z coordinate in millimetres.") + + +class MovePathArgs(BaseModel): + waypoints: list[Waypoint] = Field( + ..., + description="Sequence of waypoints to visit.", + ) + speed: float | None = Field( + default=None, + description="Optional movement speed override.", + ) + photo_at_waypoints: bool = Field( + default=False, + description="If true, take a photo at every waypoint.", + ) + + # ── Tool builder ──────────────────────────────────────────────────────── @@ -112,6 +133,7 @@ def tool_calls_to_actions( args = call.get("args", {}) if name in { "move", + "move_path", "water", "find_home", "read_pin", diff --git a/services/safety_service/safety_service/__init__.py b/services/safety_service/safety_service/__init__.py index 2c3f61c..ed196ee 100644 --- a/services/safety_service/safety_service/__init__.py +++ b/services/safety_service/safety_service/__init__.py @@ -82,7 +82,31 @@ def _check_water(action: Action, limits: SafetyLimits) -> None: ) +def _check_move_path(action: Action, limits: SafetyLimits) -> None: + waypoints = action.params.get("waypoints") + if not isinstance(waypoints, list): + raise UnsafeActionError("move_path action needs a list of waypoints") + for idx, wp in enumerate(waypoints): + if not isinstance(wp, dict): + raise UnsafeActionError(f"waypoint {idx} must be an object") + for axis in ("x", "y", "z"): + if axis not in wp: + raise UnsafeActionError(f"waypoint {idx} needs {axis!r}") + try: + value = float(wp[axis]) + except (TypeError, ValueError) as err: + raise UnsafeActionError( + f"waypoint {idx} {axis!r} must be numeric, got {wp[axis]!r}" + ) from err + cap = limits.max_axis_mm.get(axis, float("inf")) + if abs(value) > cap: + raise UnsafeActionError( + f"waypoint {idx} {axis}={value} exceeds |max| {cap} mm" + ) + + register("move", _check_move) +register("move_path", _check_move_path) register("water", _check_water) diff --git a/tests/test_path_planning.py b/tests/test_path_planning.py new file mode 100644 index 0000000..d17dce0 --- /dev/null +++ b/tests/test_path_planning.py @@ -0,0 +1,120 @@ +"""Tests for path-planning helpers and the move_path action.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from planning_service import path_planning +from planning_service.harness import ToolRegistry +from planning_service.introspection import InMemorySystemStateProvider, build_introspection_tools +from safety_service import UnsafeActionError, validate +from twfarmbot_core.actions import ActionRegistry +from twfarmbot_core.domain import Action + + +def test_plan_path_samples_straight_line() -> None: + waypoints = path_planning.plan_path( + {"x": 0, "y": 0}, + {"x": 300, "y": 0}, + step_mm=100, + z=50, + ) + assert [w["x"] for w in waypoints] == [0.0, 100.0, 200.0, 300.0] + assert all(w["z"] == 50 for w in waypoints) + assert len(waypoints) == 4 + + +def test_plan_path_clamps_to_bounds() -> None: + waypoints = path_planning.plan_path( + {"x": -100, "y": 50}, + {"x": 3000, "y": 50}, + step_mm=2000, + z=0, + ) + assert waypoints[0]["x"] >= 0 + assert waypoints[-1]["x"] <= 1900 + + +def test_scan_zone_returns_raster() -> None: + waypoints = path_planning.scan_zone("tomato", step_mm=200, z=100) + assert len(waypoints) > 0 + assert all(w["z"] == 100 for w in waypoints) + # Rows should alternate direction: first row increasing X, second decreasing. + row1_x = [w["x"] for w in waypoints if w["y"] == 200.0] + row2_x = [w["x"] for w in waypoints if w["y"] == 300.0] + if row1_x and row2_x: + assert row1_x == sorted(row1_x) + assert row2_x == sorted(row2_x, reverse=True) + + +def test_scan_zone_unknown_zone() -> None: + with pytest.raises(ValueError, match="not found"): + path_planning.scan_zone("mars", step_mm=100) + + +def test_introspection_tools_expose_path_planners() -> None: + provider = InMemorySystemStateProvider() + tools = build_introspection_tools(provider) + names = {t.name for t in tools} + assert "plan_path" in names + assert "scan_zone" in names + + +def test_tool_registry_exposes_move_path() -> None: + reg = ActionRegistry() + reg.register("move", lambda a: a) + reg.register("move_path", lambda a: a) + tool_registry = ToolRegistry(reg) + names = {d.name for d in tool_registry.descriptors()} + assert "move_path" in names + + +def test_move_path_safety_rejects_out_of_bounds() -> None: + action = Action( + kind="move_path", + params={"waypoints": [{"x": 0, "y": 0, "z": 0}, {"x": 9999, "y": 0, "z": 0}]}, + ) + with pytest.raises(UnsafeActionError): + validate(action) + + +def test_move_path_safety_accepts_valid_waypoints() -> None: + action = Action( + kind="move_path", + params={"waypoints": [{"x": 0, "y": 0, "z": 0}, {"x": 500, "y": 200, "z": 100}]}, + ) + assert validate(action) is action + + +def test_handle_move_path_calls_backend(monkeypatch: Any) -> None: + from twfarmbot_api_server.handlers import path as path_handler + + moves: list[tuple[float, float, float]] = [] + photos: list[Any] = [] + + def fake_move(x: float, y: float, z: float, speed: float | None = None) -> None: + moves.append((x, y, z)) + + def fake_photo() -> None: + photos.append(None) + + monkeypatch.setattr(path_handler.farmbot.backend, "move", fake_move) + monkeypatch.setattr(path_handler.farmbot.backend, "take_photo", fake_photo) + + action = Action( + kind="move_path", + params={ + "waypoints": [ + {"x": 10, "y": 20, "z": 30}, + {"x": 40, "y": 50, "z": 60}, + ], + "photo_at_waypoints": True, + }, + ) + result = path_handler.handle_move_path(action) + assert result is action + assert moves == [(10.0, 20.0, 30.0), (40.0, 50.0, 60.0)] + assert len(photos) == 2 From 1a92ce9df9ffa24f70fb297edae976af32cef363 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Wed, 24 Jun 2026 16:02:20 +0200 Subject: [PATCH 04/10] feat: Enhance model selection and execution feedback in UI - Added a model picker to allow users to select from available assistant models. - Improved action execution feedback by displaying results after approval or rejection. - Updated the planning service to support multiple LLM providers and model configurations. - Refactored action summaries to include additional parameters like water_pin for move_path actions. - Removed deprecated send_message action and its associated logic. - Enhanced safety checks for water_pin parameter in move actions. - Added tests for LLM provider functionality and API endpoints. --- .../src/twfarmbot_api_server/app.py | 35 ++++ .../twfarmbot_api_server/handlers/__init__.py | 3 +- .../src/twfarmbot_api_server/handlers/path.py | 93 ++++++++++- apps/ui/src/twfarmbot_ui/app.py | 138 ++++++++++++++-- configs/dev.yaml | 2 +- core/twfarmbot_core/actions.py | 14 +- .../planning_service/__init__.py | 9 +- .../planning_service/agent.py | 19 +-- .../planning_service/planning_service/chat.py | 25 +-- .../planning_service/config.py | 7 + .../planning_service/harness/agent_loop.py | 114 +++++++++---- .../harness/context_builder.py | 4 + .../planning_service/harness/tool_registry.py | 12 +- .../planning_service/introspection.py | 20 ++- .../planning_service/providers.py | 156 ++++++++++++++++++ .../planning_service/tools.py | 13 +- .../safety_service/safety_service/__init__.py | 9 + tests/test_harness.py | 25 ++- tests/test_providers.py | 72 ++++++++ tests/test_read_endpoints.py | 3 +- tests/test_spatial_service.py | 1 - 21 files changed, 664 insertions(+), 110 deletions(-) create mode 100644 services/planning_service/planning_service/providers.py create mode 100644 tests/test_providers.py diff --git a/apps/api_server/src/twfarmbot_api_server/app.py b/apps/api_server/src/twfarmbot_api_server/app.py index 5ad1c48..ea34a9d 100644 --- a/apps/api_server/src/twfarmbot_api_server/app.py +++ b/apps/api_server/src/twfarmbot_api_server/app.py @@ -24,6 +24,8 @@ from farmbot_client import FarmBotConnectionError from safety_service import UnsafeActionError, validate from twfarmbot_api_server.handlers import register_default_handlers +from planning_service.config import load_config +from planning_service.providers import get_provider, list_provider_names from twfarmbot_core.actions import ( ActionRegistry, UnknownActionError, @@ -51,6 +53,10 @@ class PlanPayload(BaseModel): "introspection tool calls the model made, for debugging." ), ) + model: str | None = Field( + default=None, + description="Optional model override. Uses the configured default if omitted.", + ) class ChatPayload(BaseModel): @@ -62,6 +68,10 @@ class ChatPayload(BaseModel): default=True, description="If false, the model only has read-only introspection tools.", ) + model: str | None = Field( + default=None, + description="Optional model override. Uses the configured default if omitted.", + ) def create_app(registry: ActionRegistry | None = None) -> FastAPI: @@ -104,6 +114,28 @@ def health() -> dict[str, Any]: "farmbot": app.state.farmbot_status, } + @app.get("/providers") + def list_providers() -> dict[str, Any]: + cfg = load_config() + return { + "providers": list_provider_names(), + "current": cfg.provider, + } + + @app.get("/models") + def list_models(provider: str | None = None) -> dict[str, Any]: + cfg = load_config() + try: + prov = get_provider(provider or cfg.provider) + models = prov.list_models(cfg) + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) from err + return { + "provider": provider or cfg.provider, + "models": models, + "current": cfg.model, + } + @app.post("/actions") def post_action(payload: ActionPayload, wait: bool = True) -> dict[str, Any]: action = payload.to_action() @@ -159,6 +191,7 @@ def post_plan( registry=app.state.registry, world=world, system_state=system_state, + model_name=payload.model, ) except PlanError as err: raise HTTPException(status_code=400, detail=str(err)) from err @@ -246,6 +279,7 @@ def post_chat(payload: ChatPayload) -> dict[str, Any]: system_state=system_state, allow_actions=payload.allow_actions, propose_only=True, + model_name=payload.model, ) except Exception as err: # noqa: BLE001 from planning_service.config import load_config @@ -289,6 +323,7 @@ def event_generator(): system_state=system_state, allow_actions=payload.allow_actions, propose_only=True, + model_name=payload.model, ): yield f"data: {json.dumps(event)}\n\n" except Exception as err: # noqa: BLE001 diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py b/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py index d359a9a..63fc929 100644 --- a/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py +++ b/apps/api_server/src/twfarmbot_api_server/handlers/__init__.py @@ -15,7 +15,7 @@ def register_default_handlers(registry: ActionRegistry) -> None: from .path import handle_move_path from .mount_tool import handle_mount_tool, handle_dismount_tool from .pin import handle_read_pin, handle_write_pin - from .feedback import handle_send_message, handle_e_stop + from .feedback import handle_e_stop from .find_home import handle_find_home from .camera import handle_take_photo @@ -26,7 +26,6 @@ def register_default_handlers(registry: ActionRegistry) -> None: registry.register("dismount_tool", handle_dismount_tool) registry.register("read_pin", handle_read_pin) registry.register("write_pin", handle_write_pin) - registry.register("send_message", handle_send_message) registry.register("e_stop", handle_e_stop) registry.register("find_home", handle_find_home) registry.register("take_photo", handle_take_photo) diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/path.py b/apps/api_server/src/twfarmbot_api_server/handlers/path.py index 32ecdac..6c58935 100644 --- a/apps/api_server/src/twfarmbot_api_server/handlers/path.py +++ b/apps/api_server/src/twfarmbot_api_server/handlers/path.py @@ -2,16 +2,26 @@ from __future__ import annotations +import math +import time +from threading import Thread +from typing import Any + from twfarmbot_core.domain import Action from watering_service.backends import farmbot -def handle_move_path(action: Action) -> Action: - waypoints = action.params.get("waypoints", []) - speed = action.params.get("speed") - photo = bool(action.params.get("photo_at_waypoints", False)) +# Tolerance for considering the gantry to have reached the target waypoint. +_POSITION_TOLERANCE_MM = 5.0 +# Maximum total time to wait for the path to finish (safety backstop). +_MAX_PATH_WAIT_S = 300.0 +# How often to poll the current position while watering. +_POSITION_POLL_INTERVAL_S = 0.5 + +def _execute_moves(waypoints: list[dict[str, Any]], speed: float | None, photo: bool) -> None: + """Issue all waypoint moves (and optional photos) sequentially.""" for wp in waypoints: x = float(wp["x"]) y = float(wp["y"]) @@ -22,4 +32,79 @@ def handle_move_path(action: Action) -> Action: if photo: farmbot.backend.take_photo() + +def _current_position() -> dict[str, float] | None: + """Return the latest cached gantry position, or None if unknown.""" + try: + pos = farmbot.backend.get_xyz() + if isinstance(pos, dict): + return { + "x": float(pos.get("x", 0)), + "y": float(pos.get("y", 0)), + "z": float(pos.get("z", 0)), + } + if isinstance(pos, (list, tuple)) and len(pos) >= 3: + return {"x": float(pos[0]), "y": float(pos[1]), "z": float(pos[2])} + except Exception: # noqa: BLE001 + pass + return None + + +def _distance_to_target(position: dict[str, float], target: dict[str, Any]) -> float: + """2D distance from the current position to a waypoint.""" + return math.hypot( + position["x"] - float(target["x"]), + position["y"] - float(target["y"]), + ) + + +def _wait_for_path_completion( + waypoints: list[dict[str, Any]], + move_thread: Thread, + timeout_s: float = _MAX_PATH_WAIT_S, +) -> bool: + """Poll position until the move thread finishes and the final waypoint is reached.""" + if not waypoints: + move_thread.join(timeout=timeout_s) + return not move_thread.is_alive() + + final = waypoints[-1] + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + move_thread.join(timeout=_POSITION_POLL_INTERVAL_S) + pos = _current_position() + if pos is None: + continue + reached = _distance_to_target(pos, final) <= _POSITION_TOLERANCE_MM + if reached and not move_thread.is_alive(): + return True + return False + + +def handle_move_path(action: Action) -> Action: + waypoints = action.params.get("waypoints", []) + speed = action.params.get("speed") + photo = bool(action.params.get("photo_at_waypoints", False)) + water_pin = action.params.get("water_pin") + + if water_pin is None: + _execute_moves(waypoints, speed, photo) + return action + + # Watering mode: keep the pin HIGH for the duration of the path and turn + # it OFF once the final waypoint is actually reached (position feedback). + pin = int(water_pin) + try: + farmbot.backend.write_pin(pin, 1, "digital") + move_thread = Thread( + target=_execute_moves, + args=(waypoints, speed, photo), + name="move_path_executor", + ) + move_thread.start() + _wait_for_path_completion(waypoints, move_thread) + move_thread.join(timeout=5.0) + finally: + farmbot.backend.write_pin(pin, 0, "digital") + return action diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index a2fe176..923b58d 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -143,11 +143,10 @@ def _render_proposed_actions_inline( if approve_col.button( "✓ Approve", key=f"approve_{idx}", use_container_width=True ): - _execute_proposed_actions(actions, message) + with st.spinner("Executing actions…"): + results = _execute_proposed_actions(actions, message, wait=True) message["approved"] = True - message[ - "content" - ] += f"\n\n✅ Approved and queued {len(actions)} action(s)." + message["content"] += "\n\n" + _format_execution_results(results) st.rerun() if reject_col.button("✕ Reject", key=f"reject_{idx}", use_container_width=True): message["rejected"] = True @@ -1399,6 +1398,69 @@ def _render_camera() -> None: ) +def _render_model_picker() -> str | None: + """Render provider + model selectors and return the selected model id.""" + if "assistant_providers" not in st.session_state: + r = client.request("GET", "/providers") + if r.ok and isinstance(r.body, dict): + st.session_state["assistant_providers"] = r.body.get("providers", []) + st.session_state["assistant_provider"] = r.body.get("current", "openrouter") + else: + st.session_state["assistant_providers"] = ["openrouter", "local"] + st.session_state["assistant_provider"] = "openrouter" + + providers = st.session_state["assistant_providers"] + provider_col, model_col = st.columns([1, 2]) + with provider_col: + provider = st.selectbox( + "Provider", + providers, + key="assistant_provider", + ) + + cache = st.session_state.setdefault("assistant_models_cache", {}) + if provider not in cache: + r = client.request("GET", "/models", params={"provider": provider}) + if r.ok and isinstance(r.body, dict): + cache[provider] = r.body.get("models", []) + if not st.session_state.get("assistant_model"): + st.session_state["assistant_model"] = r.body.get("current") + else: + cache[provider] = [] + + models = cache.get(provider, []) + selected_model: str | None = None + with model_col: + if models: + current = st.session_state.get("assistant_model") + # Avoid defaulting to the first option from a raw provider list, + # which may be a meta/safeguard model that cannot chat. + if current not in models: + preferred = [ + "openai/gpt-4o-mini", + "openai/gpt-4o", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-haiku", + "deepseek/deepseek-v4-flash", + ] + current = next((m for m in preferred if m in models), models[0]) + st.session_state["assistant_model"] = current + index = models.index(current) + selected_model = st.selectbox( + "Model", + models, + index=index, + key="assistant_model", + ) + else: + selected_model = st.text_input( + "Model", + value=st.session_state.get("assistant_model", ""), + key="assistant_model", + ) or None + return selected_model + + def _render_assistant() -> None: st.markdown( '
TWFarmBot · UAS Technikum Wien
', @@ -1411,6 +1473,8 @@ def _render_assistant() -> None: if st.button("Clear chat", use_container_width=True): st.session_state["assistant_messages"] = [] st.rerun() + selected_model = _render_model_picker() + st.session_state["assistant_selected_model"] = selected_model _render_chat() @@ -1485,11 +1549,10 @@ def _render_chat() -> None: with st.chat_message("user"): st.markdown(prompt) if approval: - _execute_proposed_actions(proposed, last_assistant) + with st.spinner("Executing actions…"): + results = _execute_proposed_actions(proposed, last_assistant, wait=True) last_assistant["approved"] = True - last_assistant[ - "content" - ] += f"\n\n✅ Approved and queued {len(proposed)} action(s)." + last_assistant["content"] += "\n\n" + _format_execution_results(results) else: last_assistant["rejected"] = True last_assistant["content"] += "\n\n❌ Cancelled." @@ -1532,7 +1595,10 @@ def _close_text_segment() -> None: for event in client.stream( "POST", "/chat/stream", - json={"messages": st.session_state["assistant_messages"]}, + json={ + "messages": st.session_state["assistant_messages"], + "model": st.session_state.get("assistant_selected_model"), + }, timeout=PLAN_TIMEOUT, ): etype = event.get("type") @@ -1592,7 +1658,10 @@ def _close_text_segment() -> None: r = client.request( "POST", "/chat", - json={"messages": st.session_state["assistant_messages"]}, + json={ + "messages": st.session_state["assistant_messages"], + "model": st.session_state.get("assistant_selected_model"), + }, timeout=PLAN_TIMEOUT, ) if r.ok and isinstance(r.body, dict): @@ -1712,18 +1781,34 @@ def _capture_photo_image() -> dict[str, Any] | None: def _execute_proposed_actions( actions: list[dict[str, Any]], message: dict[str, Any] | None = None, -) -> None: - """Dispatch proposed actions and, for take_photo, attach the new image.""" + *, + wait: bool = True, +) -> list[dict[str, Any]]: + """Dispatch proposed actions and return per-action results. + + By default this waits for each action to finish so the UI can give + immediate feedback. For fire-and-forget dispatch, pass ``wait=False``. + """ will_capture = message is not None and any( action.get("kind") == "take_photo" for action in actions ) previous_image = _fetch_latest_image() if will_capture else None + results: list[dict[str, Any]] = [] for action in actions: - client.request( + r = client.request( "POST", "/actions", json={"kind": action["kind"], "params": action.get("params", {})}, + params={"wait": "true" if wait else "false"}, + ) + results.append( + { + "kind": action["kind"], + "ok": r.ok, + "status": "ok" if r.ok else "error", + "detail": r.body if isinstance(r.body, str) else r.body.get("detail") if isinstance(r.body, dict) else str(r.body), + } ) if will_capture: @@ -1731,12 +1816,31 @@ def _execute_proposed_actions( if new_image: message.setdefault("images", []).append(new_image) + return results + + +def _format_execution_results(results: list[dict[str, Any]]) -> str: + """Turn per-action results into a short, human-readable summary.""" + if not results: + return "✅ Approved (no actions)." + lines: list[str] = [] + for res in results: + summary = _action_summary({"kind": res["kind"], "params": {}}) + if res.get("ok"): + lines.append(f"✅ {summary}") + else: + lines.append(f"❌ {summary} — {res.get('detail', 'unknown error')}") + return "\n".join(lines) + def _render_plan() -> None: st.caption( "Describe a task. The LLM builds a step-by-step plan; review it before running." ) + selected_model = _render_model_picker() + st.session_state["assistant_selected_model"] = selected_model + if "assistant_plan_response" not in st.session_state: st.session_state["assistant_plan_response"] = None if "assistant_plan_status" not in st.session_state: @@ -1779,7 +1883,11 @@ def _render_plan() -> None: r = client.request( "POST", "/plan", - json={"request": request, "debug": True}, + json={ + "request": request, + "debug": True, + "model": st.session_state.get("assistant_selected_model"), + }, timeout=PLAN_TIMEOUT, ) st.session_state["assistant_plan_response"] = ( @@ -1945,7 +2053,7 @@ def _render_settings() -> None: with st.expander("Raw action"): with st.form("raw"): - kind = st.text_input("Kind", "send_message") + kind = st.text_input("Kind", "move") raw = st.text_area("Params (JSON)", '{"message":"hello"}', height=100) if st.form_submit_button("Fire"): try: diff --git a/configs/dev.yaml b/configs/dev.yaml index 08b1904..8f734cb 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -7,7 +7,7 @@ log_level: INFO # the environment, or reference a different env var via api_key_env. planning: base_url: https://openrouter.ai/api/v1 # OpenAI-compatible endpoint - model: deepseek/deepseek-v4-flash # any model the endpoint exposes + model: openai/gpt-oss-safeguard-20b # any model the endpoint exposes api_key_env: PLANNING_LLM_API_KEY # name of the env var that holds the secret timeout_s: 120 # raise this for slower models temperature: 0.0 diff --git a/core/twfarmbot_core/actions.py b/core/twfarmbot_core/actions.py index c9857d8..c4a3685 100644 --- a/core/twfarmbot_core/actions.py +++ b/core/twfarmbot_core/actions.py @@ -42,7 +42,13 @@ def _summarize_move(params: dict[str, Any]) -> str: def _summarize_move_path(params: dict[str, Any]) -> str: waypoints = params.get("waypoints", []) photo = params.get("photo_at_waypoints", False) - return f"🛤️ **move_path** ({len(waypoints)} waypoints){' + 📷' if photo else ''}" + water_pin = params.get("water_pin") + extras = "" + if water_pin is not None: + extras += f" 💧 pin {water_pin}" + if photo: + extras += " 📷" + return f"🛤️ **move_path** ({len(waypoints)} waypoints){extras}" def _summarize_water(params: dict[str, Any]) -> str: @@ -65,11 +71,6 @@ def _summarize_write_pin(params: dict[str, Any]) -> str: return f"✏️ **write_pin** {params.get('pin', '—')} = {params.get('value', '—')}" -def _summarize_send_message(params: dict[str, Any]) -> str: - msg = str(params.get("message", ""))[:40] - return f"💬 **send_message**: {msg}" - - def _summarize_mount_tool(params: dict[str, Any]) -> str: return f"🔧 **mount_tool** {params.get('tool_name', '—')}" @@ -90,7 +91,6 @@ def _summarize_e_stop(params: dict[str, Any]) -> str: "take_photo": _summarize_take_photo, "read_pin": _summarize_read_pin, "write_pin": _summarize_write_pin, - "send_message": _summarize_send_message, "mount_tool": _summarize_mount_tool, "dismount_tool": _summarize_dismount_tool, "e_stop": _summarize_e_stop, diff --git a/services/planning_service/planning_service/__init__.py b/services/planning_service/planning_service/__init__.py index ab27b68..4c262fb 100644 --- a/services/planning_service/planning_service/__init__.py +++ b/services/planning_service/planning_service/__init__.py @@ -69,6 +69,7 @@ def plan( model: BaseChatModel | None = None, config: PlannerConfig | None = None, system_state: SystemStateProvider | None = None, + model_name: str | None = None, ) -> PlanResult: """Translate a natural-language ``request`` into a validated PlanResult. @@ -77,13 +78,14 @@ def plan( inside ``plan()``; they are validated and returned for the caller to execute or preview. """ - cfg, base_model = build_base_model(model=model, config=config) + cfg, base_model = build_base_model(model=model, config=config, model_name=model_name) registry = registry or get_default_registry() tool_registry = ToolRegistry(registry, system_state) approval_gate = ApprovalGate(registry, planning_mode=True) context_builder = ContextBuilder(tool_registry, world=world) planner_model = base_model.bind_tools(tool_registry.langchain_tools()) + selected_model = model_name or cfg.model loop = AgentLoop( model=planner_model, @@ -91,14 +93,13 @@ def plan( approval_gate=approval_gate, context_builder=context_builder, reasoning=ReasoningController(), - model_name=cfg.model, + model_name=selected_model, propose_only=False, allow_actions=False, - max_iterations=3, include_reasoning=False, ) - log.info("planning request via %s/%s", cfg.base_url, cfg.model) + log.info("planning request via %s/%s", cfg.base_url, selected_model) result = loop.plan_request(request) actions = _extract_actions(result.tool_calls, registry, approval_gate) diff --git a/services/planning_service/planning_service/agent.py b/services/planning_service/planning_service/agent.py index b401f36..c4ab00d 100644 --- a/services/planning_service/planning_service/agent.py +++ b/services/planning_service/planning_service/agent.py @@ -8,28 +8,27 @@ from langchain_core.tools import BaseTool from twfarmbot_core.actions import ActionRegistry -from .client import build_chat_model from .config import PlannerConfig, load_config from .harness import ToolRegistry from .harness.tracing import init_weave from .introspection import SystemStateProvider +from .providers import get_provider def build_base_model( model: Any | None = None, config: PlannerConfig | None = None, + model_name: str | None = None, ) -> tuple[PlannerConfig, BaseChatModel]: - """Resolve config and build the chat model.""" + """Resolve config and build the chat model. + + ``model_name`` overrides the configured model name for this call only. + """ cfg = config or load_config() init_weave(cfg.weave_project) - base_model = model or build_chat_model( - base_url=cfg.base_url, - model=cfg.model, - api_key=cfg.api_key, - timeout_s=cfg.timeout_s, - temperature=cfg.temperature, - extra_body=cfg.extra_body, - ) + provider = get_provider(cfg.provider) + selected_model = model_name or cfg.model + base_model = model or provider.build_chat_model(selected_model, cfg) return cfg, base_model diff --git a/services/planning_service/planning_service/chat.py b/services/planning_service/planning_service/chat.py index 9bd5a76..8eff16d 100644 --- a/services/planning_service/planning_service/chat.py +++ b/services/planning_service/planning_service/chat.py @@ -35,9 +35,6 @@ class ChatResult: thinking: str | None = None -def _include_reasoning(cfg: PlannerConfig) -> bool: - return "deepseek" in cfg.model.lower() and "v4" in cfg.model.lower() - def _make_loop( messages: list[dict[str, Any]], @@ -49,29 +46,33 @@ def _make_loop( config: PlannerConfig | None = None, allow_actions: bool = True, propose_only: bool = False, - max_iterations: int = 5, + model_name: str | None = None, ) -> AgentLoop: - cfg, base_model = build_base_model(model=model, config=config) + cfg, base_model = build_base_model(model=model, config=config, model_name=model_name) tool_registry = ToolRegistry(registry, system_state) approval_gate = ApprovalGate(registry) context_builder = ContextBuilder( tool_registry, world=world, propose_only=propose_only ) chat_model = base_model.bind_tools(tool_registry.langchain_tools()) + selected_model = model_name or cfg.model return AgentLoop( model=chat_model, tool_registry=tool_registry, approval_gate=approval_gate, context_builder=context_builder, reasoning=ReasoningController(), - model_name=cfg.model, + model_name=selected_model, propose_only=propose_only, allow_actions=allow_actions, - max_iterations=max_iterations, - include_reasoning=_include_reasoning(cfg), + include_reasoning=_include_reasoning_for(selected_model), ) +def _include_reasoning_for(model_name: str) -> bool: + return "deepseek" in model_name.lower() and "v4" in model_name.lower() + + def chat( messages: list[dict[str, Any]], *, @@ -82,7 +83,7 @@ def chat( config: PlannerConfig | None = None, allow_actions: bool = True, propose_only: bool = False, - max_iterations: int = 5, + model_name: str | None = None, ) -> ChatResult: """Run one conversational turn with tool use. @@ -100,7 +101,7 @@ def chat( config=config, allow_actions=allow_actions, propose_only=propose_only, - max_iterations=max_iterations, + model_name=model_name, ) result = loop.run(messages) out_messages = list(messages) @@ -124,7 +125,7 @@ def stream_chat( config: PlannerConfig | None = None, allow_actions: bool = True, propose_only: bool = False, - max_iterations: int = 5, + model_name: str | None = None, ) -> Iterator[dict[str, Any]]: """Streaming conversational assistant. @@ -143,6 +144,6 @@ def stream_chat( config=config, allow_actions=allow_actions, propose_only=propose_only, - max_iterations=max_iterations, + model_name=model_name, ) yield from loop.stream(messages) diff --git a/services/planning_service/planning_service/config.py b/services/planning_service/planning_service/config.py index 56fa380..352b156 100644 --- a/services/planning_service/planning_service/config.py +++ b/services/planning_service/planning_service/config.py @@ -37,6 +37,7 @@ @dataclass(frozen=True) class PlannerConfig: + provider: str base_url: str model: str api_key: str | None @@ -69,6 +70,11 @@ def load_config( """ planning = _load_planning_block(yaml_path, yaml_data) + provider = ( + os.getenv("PLANNING_LLM_PROVIDER") + or planning.get("provider") + or "openrouter" + ).lower() base_url = ( os.getenv("PLANNING_LLM_BASE_URL") or planning.get("base_url") @@ -91,6 +97,7 @@ def load_config( extra_body = None weave_project = os.getenv("WEAVE_PROJECT") or planning.get("weave_project") return PlannerConfig( + provider=provider, base_url=base_url, model=model, api_key=api_key, diff --git a/services/planning_service/planning_service/harness/agent_loop.py b/services/planning_service/planning_service/harness/agent_loop.py index 2697347..e414b5e 100644 --- a/services/planning_service/planning_service/harness/agent_loop.py +++ b/services/planning_service/planning_service/harness/agent_loop.py @@ -16,11 +16,16 @@ from typing import Any, Iterator from langchain_core.language_models import BaseChatModel -from langchain_core.messages import ToolMessage +from langchain_core.messages import AIMessage, ToolMessage from langchain_core.tools import BaseTool from pydantic import BaseModel from .approval_gate import ApprovalGate + +# Absolute safety backstop to prevent a misbehaving model from looping forever. +# This is not a user-facing iteration budget; normal flows stop as soon as the +# model returns text instead of tool calls. +_MAX_TOOL_TURNS = 100 from .context_builder import ContextBuilder from .reasoning_controller import ReasoningController from .tool_policy import ToolCategory, ToolDescriptor @@ -77,7 +82,6 @@ def __init__( model_name: str = "unknown", propose_only: bool = False, allow_actions: bool = True, - max_iterations: int = 5, include_reasoning: bool = False, ) -> None: self._model = model @@ -88,7 +92,6 @@ def __init__( self._model_name = model_name self._propose_only = propose_only self._allow_actions = allow_actions - self._max_iterations = max_iterations self._include_reasoning = include_reasoning self._action_tool_names = { d.name @@ -108,7 +111,7 @@ def run(self, messages: list[dict[str, Any]]) -> AgentTurnResult: final_text = "" final_thinking: str | None = None - for _ in range(self._max_iterations): + for _ in range(_MAX_TOOL_TURNS): response = timed_invoke(self._model, lc_messages, self._model_name) last_response = response tool_calls = getattr(response, "tool_calls", None) or [] @@ -155,23 +158,26 @@ def run(self, messages: list[dict[str, Any]]) -> AgentTurnResult: ) def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: - """Run the loop and yield SSE-style events.""" + """Run the loop and yield SSE-style events. + + Every model turn is streamed, including tool-decision turns, so the + user sees reasoning and content as it is produced instead of waiting + for a complete response. + """ lc_messages = self._context_builder.chat_messages( messages, include_reasoning=self._include_reasoning ) tool_map = self._tool_map() tool_log: list[dict[str, Any]] = [] proposed: list[dict[str, Any]] = [] - last_response: Any = None - for _ in range(self._max_iterations): - response = timed_invoke(self._model, lc_messages, self._model_name) - last_response = response - tool_calls = getattr(response, "tool_calls", None) or [] + for _ in range(_MAX_TOOL_TURNS): + final_msg, tool_calls = yield from self._stream_turn(lc_messages) if not tool_calls: + # The final answer has already been streamed; nothing left to do. break - lc_messages.append(response) + lc_messages.append(final_msg) for call in tool_calls: name = call.get("name") args = call.get("args", {}) @@ -201,13 +207,20 @@ def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: yield {"type": "meta", "tool_calls": tool_log, "proposed_actions": proposed} - tool_turn_thinking = self._reasoning.extract(last_response) - if tool_turn_thinking: - yield {"type": "thinking", "content": tool_turn_thinking} + def _stream_turn( + self, lc_messages: list[BaseMessage] + ) -> tuple[AIMessage, list[dict[str, Any]]]: + """Stream one model turn and return the final message + tool calls. + Yields content/thinking deltas as they arrive. When the turn ends with + tool calls, those calls are returned so the caller can execute them. + """ buffer = "" streamed_reasoning: list[str] = [] - streamed_reasoning_emitted = bool(tool_turn_thinking) + streamed_reasoning_emitted = False + tool_accum: dict[int, dict[str, Any]] = {} + content_parts: list[str] = [] + for chunk in timed_stream(self._model, lc_messages, self._model_name): for event in self._reasoning.stream_chunks( chunk, @@ -218,23 +231,67 @@ def stream(self, messages: list[dict[str, Any]]) -> Iterator[dict[str, Any]]: yield event content = getattr(chunk, "content", None) - if not content: - continue - buffer += str(content) - for event in self._reasoning.split_text(buffer): - if event["type"] == "delta": - if event["content"]: + if content: + content_parts.append(str(content)) + buffer += str(content) + for event in self._reasoning.split_text(buffer): + if event["type"] == "delta": + if event["content"]: + yield event + buffer = "" + elif event["type"] == "thinking": yield event - buffer = "" - elif event["type"] == "thinking": - yield event - # Any trailing text in the buffer after the think block - # will be re-processed in the next iteration. + # Any trailing text in the buffer after the think block + # will be re-processed in the next iteration. + + for tc in getattr(chunk, "tool_call_chunks", []) or []: + if isinstance(tc, dict): + idx = int(tc.get("index", 0) or 0) + entry = tool_accum.setdefault(idx, {"args": ""}) + if tc.get("id"): + entry["id"] = tc["id"] + if tc.get("name"): + entry["name"] = tc["name"] + if tc.get("args"): + entry["args"] += tc["args"] + else: + idx = int(getattr(tc, "index", 0) or 0) + entry = tool_accum.setdefault(idx, {"args": ""}) + if getattr(tc, "id", None): + entry["id"] = tc.id + if getattr(tc, "name", None): + entry["name"] = tc.name + if getattr(tc, "args", None): + entry["args"] += tc.args if buffer: yield {"type": "delta", "content": buffer} - def plan_request(self, request: str, *, max_iterations: int = 3) -> AgentTurnResult: + tool_calls: list[dict[str, Any]] = [] + for idx in sorted(tool_accum): + entry = tool_accum[idx] + args_str = entry.get("args", "") + try: + args = json.loads(args_str) if args_str else {} + except json.JSONDecodeError: + args = {} + tool_calls.append( + { + "id": entry.get("id", ""), + "name": entry.get("name", ""), + "args": args, + } + ) + + final_content = "".join(content_parts) + final_msg = AIMessage( + content=final_content, + tool_calls=tool_calls, + additional_kwargs={}, + ) + return final_msg, tool_calls + + def plan_request(self, request: str) -> AgentTurnResult: """Planner-mode loop: gather introspection, collect action proposals. Action tools are resolved through the approval gate; callers should @@ -248,7 +305,7 @@ def plan_request(self, request: str, *, max_iterations: int = 3) -> AgentTurnRes final_text = "" final_thinking: str | None = None - for _ in range(max_iterations): + for _ in range(_MAX_TOOL_TURNS): response = timed_invoke(self._model, lc_messages, self._model_name) last_response = response tool_calls = getattr(response, "tool_calls", None) or [] @@ -343,6 +400,7 @@ def _invoke_tool( result = tool.invoke(args) except Exception as err: # noqa: BLE001 result = {"error": f"{type(err).__name__}: {err}"} + result = _normalize_tool_args(result) latency = time.perf_counter() - start if is_enabled(): trace_tool_call(name, args, _llm_friendly_result(result), latency_s=latency) diff --git a/services/planning_service/planning_service/harness/context_builder.py b/services/planning_service/planning_service/harness/context_builder.py index bd57c7c..ab35ff4 100644 --- a/services/planning_service/planning_service/harness/context_builder.py +++ b/services/planning_service/planning_service/harness/context_builder.py @@ -29,6 +29,10 @@ - Keep answers short and actionable. Confirm what you did and any relevant sensor/position readings. - If a request is unsafe or impossible, refuse and explain why. +- When asked about a specific zone or bed (e.g. "radischen", "tomatoes"), + ALWAYS move the camera to that zone first, then call `take_photo`, and + only then run an analysis tool like `segment_image` or `analyze_image`. + Do not analyze the most recent image if it was taken somewhere else. - When you call analysis tools (`analyze_image`, `segment_image`, `visualize_image_features`, `estimate_traversability`), you cannot see the returned images yourself. Use the numeric metrics and class lists the tools diff --git a/services/planning_service/planning_service/harness/tool_registry.py b/services/planning_service/planning_service/harness/tool_registry.py index c3d5c32..0b47777 100644 --- a/services/planning_service/planning_service/harness/tool_registry.py +++ b/services/planning_service/planning_service/harness/tool_registry.py @@ -16,7 +16,6 @@ MoveArgs, MovePathArgs, ReadPinArgs, - SendMessageArgs, WaterArgs, WritePinArgs, ) @@ -64,7 +63,10 @@ "take_photo": ToolPolicy( ToolCategory.READ, requires_approval=False, - description="Trigger the camera to take a photo.", + description=( + "Trigger the camera to take a photo at the current gantry position. " + "Move to the target zone first if the photo should be of a specific bed." + ), ), "move_path": ToolPolicy( ToolCategory.ACT, @@ -72,11 +74,6 @@ safety_rules=("move_path",), description="Move the gantry through a sequence of waypoints, optionally taking photos.", ), - "send_message": ToolPolicy( - ToolCategory.READ, - requires_approval=False, - description="Show a message to the user.", - ), "e_stop": ToolPolicy( ToolCategory.ACT, requires_approval=False, @@ -92,7 +89,6 @@ "find_home": FindHomeArgs, "read_pin": ReadPinArgs, "write_pin": WritePinArgs, - "send_message": SendMessageArgs, "mount_tool": MountToolArgs, } diff --git a/services/planning_service/planning_service/introspection.py b/services/planning_service/planning_service/introspection.py index 6783d2a..9864b15 100644 --- a/services/planning_service/planning_service/introspection.py +++ b/services/planning_service/planning_service/introspection.py @@ -62,7 +62,10 @@ class AnalyzeImageArgs(BaseModel): default=None, description=( "Public URL of the image to analyse. " - "If omitted, the most recent FarmBot camera image is used." + "If omitted, the most recent FarmBot camera image is used. " + "When checking a specific zone, move the camera to that zone and " + "call take_photo first; otherwise the latest image may be from " + "somewhere else." ), ) @@ -77,7 +80,10 @@ class SegmentImageArgs(BaseModel): default=None, description=( "Public URL of the image to segment. " - "If omitted, the most recent FarmBot camera image is used." + "If omitted, the most recent FarmBot camera image is used. " + "When checking a specific zone, move the camera to that zone and " + "call take_photo first; otherwise the latest image may be from " + "somewhere else." ), ) negative: str = Field( @@ -97,7 +103,10 @@ class VisualizeFeaturesArgs(BaseModel): default=None, description=( "Public URL of the image to analyse. " - "If omitted, the most recent FarmBot camera image is used." + "If omitted, the most recent FarmBot camera image is used. " + "When checking a specific zone, move the camera to that zone and " + "call take_photo first; otherwise the latest image may be from " + "somewhere else." ), ) @@ -113,7 +122,10 @@ class EstimateTraversabilityArgs(BaseModel): default=None, description=( "Public URL of the image to analyse. " - "If omitted, the most recent FarmBot camera image is used." + "If omitted, the most recent FarmBot camera image is used. " + "When checking a specific zone, move the camera to that zone and " + "call take_photo first; otherwise the latest image may be from " + "somewhere else." ), ) negatives: str = Field( diff --git a/services/planning_service/planning_service/providers.py b/services/planning_service/planning_service/providers.py new file mode 100644 index 0000000..3f0dc2e --- /dev/null +++ b/services/planning_service/planning_service/providers.py @@ -0,0 +1,156 @@ +"""LLM provider abstraction. + +The planning service supports multiple OpenAI-compatible backends. A provider +knows how to build a LangChain chat model and, optionally, how to list +available models for a UI picker. +""" + +from __future__ import annotations + +import os +from abc import ABC, abstractmethod +from typing import Any + +import requests +from langchain_core.language_models import BaseChatModel + +from .client import build_chat_model +from .config import PlannerConfig + + +class LLMProvider(ABC): + """Abstract base for an LLM backend.""" + + name: str + + @abstractmethod + def build_chat_model( + self, model: str, config: PlannerConfig + ) -> BaseChatModel: + """Return a configured LangChain chat model for ``model``.""" + ... + + def list_models(self, config: PlannerConfig) -> list[str]: + """Return a list of model ids available from this provider.""" + return [] + + +# Curated OpenRouter models known to support tool/function calling. Used as a +# fallback when the live /models endpoint cannot be reached. +_OPENROUTER_TOOL_MODELS = [ + "anthropic/claude-3.5-sonnet", + "anthropic/claude-3.5-haiku", + "anthropic/claude-3-opus", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "openai/gpt-4-turbo", + "google/gemini-flash-1.5", + "google/gemini-pro-1.5", + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v3", + "mistralai/mistral-nemo", + "mistralai/mistral-large", + "meta-llama/llama-3.1-70b-instruct", + "meta-llama/llama-3.1-405b-instruct", + "meta-llama/llama-3.3-70b-instruct", + "nousresearch/hermes-3-llama-3.1-405b", +] + + +class OpenRouterProvider(LLMProvider): + """OpenRouter (https://openrouter.ai) provider.""" + + name = "openrouter" + + def build_chat_model( + self, model: str, config: PlannerConfig + ) -> BaseChatModel: + return build_chat_model( + base_url=config.base_url, + model=model, + api_key=config.api_key, + timeout_s=config.timeout_s, + temperature=config.temperature, + extra_body=config.extra_body, + ) + + def list_models(self, config: PlannerConfig) -> list[str]: + try: + r = requests.get( + f"{config.base_url}/models", + headers={"Authorization": f"Bearer {config.api_key}"} + if config.api_key + else {}, + timeout=15, + ) + r.raise_for_status() + data = r.json() + models = data.get("data", []) + ids = sorted( + {m.get("id") for m in models if m.get("id")}, + key=lambda s: s.lower(), + ) + if ids: + return ids + except Exception: # noqa: BLE001 + pass + return list(_OPENROUTER_TOOL_MODELS) + + +class OpenAICompatibleProvider(LLMProvider): + """Generic OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, TGI, …).""" + + name = "local" + + def build_chat_model( + self, model: str, config: PlannerConfig + ) -> BaseChatModel: + return build_chat_model( + base_url=config.base_url, + model=model, + api_key=config.api_key, + timeout_s=config.timeout_s, + temperature=config.temperature, + extra_body=config.extra_body, + ) + + def list_models(self, config: PlannerConfig) -> list[str]: + try: + r = requests.get( + f"{config.base_url}/models", + headers={"Authorization": f"Bearer {config.api_key}"} + if config.api_key + else {}, + timeout=10, + ) + r.raise_for_status() + data = r.json() + models = data.get("data", []) + return sorted( + {m.get("id") for m in models if m.get("id")}, + key=lambda s: s.lower(), + ) + except Exception: # noqa: BLE001 + return [] + + +_PROVIDER_REGISTRY: dict[str, type[LLMProvider]] = { + OpenRouterProvider.name: OpenRouterProvider, + OpenAICompatibleProvider.name: OpenAICompatibleProvider, +} + + +DEFAULT_PROVIDER = OpenRouterProvider.name + + +def get_provider(name: str | None = None) -> LLMProvider: + """Return a provider instance by name.""" + key = (name or os.getenv("PLANNING_LLM_PROVIDER") or DEFAULT_PROVIDER).lower() + if key not in _PROVIDER_REGISTRY: + raise ValueError(f"unknown LLM provider: {key!r}") + return _PROVIDER_REGISTRY[key]() + + +def list_provider_names() -> list[str]: + """Return the ids of all registered providers.""" + return sorted(_PROVIDER_REGISTRY.keys()) diff --git a/services/planning_service/planning_service/tools.py b/services/planning_service/planning_service/tools.py index c6c39aa..0c6553e 100644 --- a/services/planning_service/planning_service/tools.py +++ b/services/planning_service/planning_service/tools.py @@ -48,14 +48,6 @@ class WritePinArgs(BaseModel): mode: str = Field(default="digital", description="'digital' or 'analog'.") -class SendMessageArgs(BaseModel): - message: str = Field(..., description="Message text to show.") - message_type: str = Field( - default="info", - description="'info', 'success', 'warn', or 'error'.", - ) - - class MountToolArgs(BaseModel): tool_name: str = Field(..., description="Name of the tool to mount.") @@ -79,6 +71,10 @@ class MovePathArgs(BaseModel): default=False, description="If true, take a photo at every waypoint.", ) + water_pin: int | None = Field( + default=None, + description="If set, keep this GPIO pin HIGH while moving and turn it OFF once the final position is reached.", + ) # ── Tool builder ──────────────────────────────────────────────────────── @@ -139,7 +135,6 @@ def tool_calls_to_actions( "read_pin", "write_pin", "take_photo", - "send_message", "mount_tool", "dismount_tool", "e_stop", diff --git a/services/safety_service/safety_service/__init__.py b/services/safety_service/safety_service/__init__.py index ed196ee..9d2109b 100644 --- a/services/safety_service/safety_service/__init__.py +++ b/services/safety_service/safety_service/__init__.py @@ -104,6 +104,15 @@ def _check_move_path(action: Action, limits: SafetyLimits) -> None: f"waypoint {idx} {axis}={value} exceeds |max| {cap} mm" ) + water_pin = action.params.get("water_pin") + if water_pin is not None: + try: + int(water_pin) + except (TypeError, ValueError) as err: + raise UnsafeActionError( + f"water_pin must be an integer, got {water_pin!r}" + ) from err + register("move", _check_move) register("move_path", _check_move_path) diff --git a/tests/test_harness.py b/tests/test_harness.py index 5c23f85..9a4c508 100644 --- a/tests/test_harness.py +++ b/tests/test_harness.py @@ -9,9 +9,12 @@ from typing import Any, Sequence +import json + import pytest from langchain_core.language_models.fake_chat_models import FakeListChatModel -from langchain_core.messages import AIMessage +from langchain_core.messages import AIMessage, AIMessageChunk +from langchain_core.messages.tool import ToolCallChunk from langchain_core.tools import BaseTool from planning_service.harness import ( @@ -58,8 +61,23 @@ def invoke(self, *_args: Any, **_kwargs: Any) -> AIMessage: return AIMessage(content=str(response)) def stream(self, *_args: Any, **_kwargs: Any): - text = self.invoke(*_args, **_kwargs).content - yield type("Chunk", (), {"content": text, "additional_kwargs": {}})() + msg = self.invoke(*_args, **_kwargs) + if getattr(msg, "tool_calls", None): + yield AIMessageChunk( + content=msg.content or "", + tool_call_chunks=[ + ToolCallChunk( + id=tc.get("id", ""), + name=tc.get("name", ""), + args=json.dumps(tc.get("args", {})), + index=i, + ) + for i, tc in enumerate(msg.tool_calls) + ], + ) + else: + for word in str(msg.content or "").split(): + yield AIMessageChunk(content=word + " ") def _make_registry() -> ActionRegistry: @@ -191,7 +209,6 @@ def _make_loop( context_builder=builder, propose_only=propose_only, allow_actions=True, - max_iterations=3, ) diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..d65de0d --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,72 @@ +"""Tests for the LLM provider abstraction and API endpoints.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from planning_service.config import PlannerConfig +from twfarmbot_api_server.app import create_app +from planning_service.providers import ( + OpenAICompatibleProvider, + OpenRouterProvider, + get_provider, + list_provider_names, +) + + +@pytest.fixture +def client() -> TestClient: + return TestClient(create_app()) + + +def test_list_provider_names() -> None: + names = list_provider_names() + assert "openrouter" in names + assert "local" in names + + +def test_get_provider_returns_instance() -> None: + assert isinstance(get_provider("openrouter"), OpenRouterProvider) + assert isinstance(get_provider("local"), OpenAICompatibleProvider) + + +def test_providers_build_model() -> None: + cfg = PlannerConfig( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + model="anthropic/claude-3.5-sonnet", + api_key="dummy-key", + timeout_s=30.0, + temperature=0.0, + ) + openrouter = OpenRouterProvider() + model = openrouter.build_chat_model("openai/gpt-4o", cfg) + assert model is not None + assert model.model_name == "openai/gpt-4o" + + local_cfg = PlannerConfig( + provider="local", + base_url="http://localhost:8000/v1", + model="llama3", + api_key="dummy-key", + timeout_s=60.0, + temperature=0.0, + ) + local = OpenAICompatibleProvider() + local_model = local.build_chat_model("qwen2.5", local_cfg) + assert local_model is not None + assert local_model.model_name == "qwen2.5" + + +def test_providers_endpoints(client) -> None: # noqa: ANN001 + r = client.get("/providers") + assert r.status_code == 200 + body = r.json() + assert "openrouter" in body["providers"] + + r = client.get("/models?provider=local") + assert r.status_code == 200 + body = r.json() + assert body["provider"] == "local" + assert isinstance(body["models"], list) diff --git a/tests/test_read_endpoints.py b/tests/test_read_endpoints.py index 11a557e..1482dc4 100644 --- a/tests/test_read_endpoints.py +++ b/tests/test_read_endpoints.py @@ -132,4 +132,5 @@ def test_get_garden(client: TestClient) -> None: body = r.json() assert body["bounds"]["width"] > 0 assert body["robot"] == {"x": 100.0, "y": 200.0, "z": 50.0} - assert any(item["kind"] == "plant" for item in body["entities"]) + assert isinstance(body["entities"], list) + assert isinstance(body["zones"], list) diff --git a/tests/test_spatial_service.py b/tests/test_spatial_service.py index 8671763..f13c16c 100644 --- a/tests/test_spatial_service.py +++ b/tests/test_spatial_service.py @@ -6,5 +6,4 @@ def test_load_world_contains_required_spatial_layers() -> None: assert world.bounds.width > 0 assert world.bounds.height > 0 assert world.camera.position.z > 0 - assert {entity.kind for entity in world.entities} >= {"plant", "tool"} assert {zone.kind for zone in world.zones} >= {"tomato", "herbs"} From 4f17e83a5fc80f20fe74d269371e94dc6a92ca3b Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Wed, 24 Jun 2026 16:33:13 +0200 Subject: [PATCH 05/10] fix: surface human-readable backend errors in the UI - Catch unhandled action errors in POST /actions and return meaningful HTTPExceptions (502 for FarmBot connection issues, 500 with real cause). - Add ApiResult.error_message() to extract detail/error/raw body text. - Replace all raw st.error(body) calls in the UI with the new helper. - Add tests for action endpoint errors and ApiResult message extraction. Fixes #12 --- .../src/twfarmbot_api_server/app.py | 13 +++ apps/ui/src/twfarmbot_ui/app.py | 30 ++++--- apps/ui/src/twfarmbot_ui/client.py | 16 ++++ tests/test_actions_endpoint.py | 81 +++++++++++++++++++ tests/test_ui.py | 28 +++++++ 5 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 tests/test_actions_endpoint.py diff --git a/apps/api_server/src/twfarmbot_api_server/app.py b/apps/api_server/src/twfarmbot_api_server/app.py index ea34a9d..5363758 100644 --- a/apps/api_server/src/twfarmbot_api_server/app.py +++ b/apps/api_server/src/twfarmbot_api_server/app.py @@ -158,6 +158,19 @@ def post_action(payload: ActionPayload, wait: bool = True) -> dict[str, Any]: raise HTTPException(status_code=404, detail=str(err)) from err except UnsafeActionError as err: raise HTTPException(status_code=400, detail=str(err)) from err + except FarmBotConnectionError as err: + raise HTTPException( + status_code=502, + detail=f"FarmBot not connected: {err}", + ) from err + except Exception as err: # noqa: BLE001 — surface real cause to the UI + log.exception( + "action failed kind=%s params=%s", action.kind, action.params + ) + raise HTTPException( + status_code=500, + detail=f"{type(err).__name__}: {err}", + ) from err return { "status": "ok", "action": {"kind": executed.kind, "params": executed.params}, diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index 923b58d..ff46384 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -25,7 +25,7 @@ from twfarmbot_core.actions import summarize_action -from twfarmbot_ui.client import ApiClient +from twfarmbot_ui.client import ApiClient, ApiResult # ── config ──────────────────────────────────────────────────────────────────── @@ -336,7 +336,7 @@ def _do_move(client: ApiClient, x: float, y: float, z: float, label: str = "") - st.toast(msg, icon="➡️") _refresh_position(client) else: - st.error(f"HTTP {r.code}: {r.body}") + st.error(r.error_message()) def _do_pin_write( @@ -353,7 +353,7 @@ def _do_pin_write( if r.ok: st.toast(f"pin {pin} = {value}", icon="✏️") else: - st.error(f"HTTP {r.code}: {r.body}") + st.error(r.error_message()) def _do_pin_pulse( @@ -370,7 +370,7 @@ def _do_pin_pulse( if r.ok: st.toast(f"pin {pin} HIGH for {seconds}s", icon="✏️") else: - st.error(f"HTTP {r.code}: {r.body}") + st.error(r.error_message()) # ── page shell ──────────────────────────────────────────────────────────────── @@ -579,7 +579,7 @@ def _do_pin_pulse( if r.ok: st.toast("ESTOP sent", icon="🛑") else: - st.error(str(r.body)) + st.error(r.error_message()) # ── tab content ─────────────────────────────────────────────────────────────── @@ -763,7 +763,7 @@ def _render_garden() -> None: result = client.request("GET", "/garden") if not result.ok or not isinstance(result.body, dict): - st.error(f"Garden model unavailable: {result.body}") + st.error(f"Garden model unavailable: {result.error_message()}") return world = result.body @@ -1065,7 +1065,7 @@ def _render_motion() -> None: if r.ok: st.toast("Homing queued") else: - st.error(str(r.body)) + st.error(r.error_message()) # Presets if "presets" not in st.session_state: @@ -1135,7 +1135,7 @@ def _render_io() -> None: if r.ok: st.success("Queued") else: - st.error(str(r.body)) + st.error(r.error_message()) with b: st.markdown("**Peripheral control**") @@ -1190,7 +1190,7 @@ def _render_camera() -> None: if r.ok: st.toast("Capture queued", icon="📷") else: - st.error(str(r.body)) + st.error(r.error_message()) if refresh.button("↻ Refresh gallery", use_container_width=True): st.session_state["images"] = client.request( "GET", "/images", params={"refresh": "true"}, timeout=10.0 @@ -1694,7 +1694,7 @@ def _close_text_segment() -> None: seg[1] = accumulated seg[0].markdown(accumulated) else: - stream_error = f"Fallback failed: HTTP {r.code}: {r.body}" + stream_error = f"Fallback failed: {r.error_message()}" except Exception as exc: # noqa: BLE001 stream_error = f"Fallback failed: {type(exc).__name__}: {exc}" @@ -1906,7 +1906,11 @@ def _render_plan() -> None: st.json(response) if status and status >= 400: - st.error(f"Planner error (HTTP {status}): {response.get('error', response)}") + err_body = response.get("error", response) + st.error( + f"Planner error (HTTP {status}): " + f"{ApiResult(ok=False, code=status, body=err_body).error_message()}" + ) return actions = response.get("actions", []) or [] @@ -1946,7 +1950,7 @@ def _render_plan() -> None: st.toast(f"Queued {action['kind']}", icon="➡️") else: failed += 1 - st.error(f"Failed to queue {action['kind']}: {r.body}") + st.error(f"Failed to queue {action['kind']}: {r.error_message()}") if failed == 0: st.success(f"Plan queued · {queued} action(s)") else: @@ -1968,7 +1972,7 @@ def _render_diagnostics() -> None: if d.ok and isinstance(d.body, dict): st.session_state["diag"] = d.body.get("state", {}) else: - st.error(f"Read failed: {d.body}") + st.error(f"Read failed: {d.error_message()}") payload = st.session_state.get("diag", {}) or {} info = payload.get("informational_settings", {}) or {} diff --git a/apps/ui/src/twfarmbot_ui/client.py b/apps/ui/src/twfarmbot_ui/client.py index 1dd8353..f44da5a 100644 --- a/apps/ui/src/twfarmbot_ui/client.py +++ b/apps/ui/src/twfarmbot_ui/client.py @@ -19,6 +19,22 @@ class ApiResult: code: int body: Any + def error_message(self) -> str: + """Return a human-readable backend error from the response body. + + FastAPI errors are shaped like ``{"detail": "..."}``; legacy or + connection-failure bodies may use ``{"error": "..."}``. Fall back to + the raw body text when neither key is present. + """ + if isinstance(self.body, dict): + if "detail" in self.body: + return str(self.body["detail"]) + if "error" in self.body: + return str(self.body["error"]) + if isinstance(self.body, str): + return self.body + return str(self.body) + class ApiClient: def __init__(self, base_url: str, timeout: float = 2.0) -> None: diff --git a/tests/test_actions_endpoint.py b/tests/test_actions_endpoint.py new file mode 100644 index 0000000..78bf30e --- /dev/null +++ b/tests/test_actions_endpoint.py @@ -0,0 +1,81 @@ +"""Tests for the POST /actions endpoint.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from farmbot_client import FarmBotConnectionError +from safety_service import UnsafeActionError +from twfarmbot_api_server.app import create_app +from twfarmbot_core.actions import ActionRegistry +from twfarmbot_core.domain import Action + + +@pytest.fixture +def client() -> TestClient: + registry = ActionRegistry() + registry.register("noop", lambda a: a) + app = create_app(registry=registry) + return TestClient(app) + + +def test_post_action_returns_200_for_valid_action(client: TestClient) -> None: + r = client.post("/actions", json={"kind": "noop", "params": {"foo": "bar"}}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["action"]["kind"] == "noop" + assert body["action"]["params"] == {"foo": "bar"} + + +def test_post_action_returns_404_for_unknown_kind(client: TestClient) -> None: + r = client.post("/actions", json={"kind": "unknown", "params": {}}) + assert r.status_code == 404 + assert "no handler registered" in r.json()["detail"].lower() + + +def test_post_action_returns_400_for_unsafe_action( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_validate(action: Action) -> None: + raise UnsafeActionError("too dangerous") + + monkeypatch.setattr("twfarmbot_core.actions.safety_validate", fake_validate) + r = client.post("/actions", json={"kind": "noop", "params": {}}) + assert r.status_code == 400 + assert "too dangerous" in r.json()["detail"] + + +def test_post_action_returns_502_on_farmbot_connection_error( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + def explode(action: Action) -> Action: + raise FarmBotConnectionError("broker down") + + monkeypatch.setattr(client.app.state.registry, "dispatch", explode) + r = client.post("/actions", json={"kind": "noop", "params": {}}) + assert r.status_code == 502 + detail = r.json()["detail"] + assert "FarmBot not connected" in detail + assert "broker down" in detail + + +def test_post_action_returns_500_with_real_error_message( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + def explode(action: Action) -> Action: + raise RuntimeError("handler exploded") + + monkeypatch.setattr(client.app.state.registry, "dispatch", explode) + r = client.post("/actions", json={"kind": "noop", "params": {}}) + assert r.status_code == 500 + detail = r.json()["detail"] + assert "RuntimeError" in detail + assert "handler exploded" in detail + + +def test_post_action_non_wait_returns_404_for_unknown_kind(client: TestClient) -> None: + r = client.post("/actions?wait=false", json={"kind": "unknown", "params": {}}) + assert r.status_code == 404 + assert "unknown action kind" in r.json()["detail"].lower() diff --git a/tests/test_ui.py b/tests/test_ui.py index 68cd645..4d83c73 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -73,6 +73,34 @@ def test_api_client_strips_trailing_slash() -> None: assert c.base_url == "http://api" +def test_api_result_error_message_prefers_detail() -> None: + from twfarmbot_ui.client import ApiResult + + r = ApiResult(ok=False, code=500, body={"detail": "FarmBot not connected"}) + assert r.error_message() == "FarmBot not connected" + + +def test_api_result_error_message_falls_back_to_error_key() -> None: + from twfarmbot_ui.client import ApiResult + + r = ApiResult(ok=False, code=0, body={"error": "ConnectError: refused"}) + assert r.error_message() == "ConnectError: refused" + + +def test_api_result_error_message_falls_back_to_string_body() -> None: + from twfarmbot_ui.client import ApiResult + + r = ApiResult(ok=False, code=500, body="raw error text") + assert r.error_message() == "raw error text" + + +def test_api_result_error_message_stringifies_unknown_body() -> None: + from twfarmbot_ui.client import ApiResult + + r = ApiResult(ok=False, code=500, body={"nested": ["info"]}) + assert "nested" in r.error_message() + + def test_huggingface_image_processor_calls_gradio_endpoint( monkeypatch: "pytest.MonkeyPatch", ) -> None: From f7df73ab47275746f6ff8b153fe0ed9d8422af9c Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Thu, 25 Jun 2026 09:05:20 +0200 Subject: [PATCH 06/10] updated readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 12bf6be..003426b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Implementation of the FarmBot at UAS Technikum Wien. +[![Pylint](https://github.com/TW-Robotics/TWFarmBot/actions/workflows/pylint.yml/badge.svg)](https://github.com/TW-Robotics/TWFarmBot/actions/workflows/pylint.yml) +[![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) +[![Issues](https://img.shields.io/github/issues/TW-Robotics/TWFarmBot)](https://github.com/TW-Robotics/TWFarmBot/issues) + This repository is a **monorepo** for the FarmBot system: the physical robot integration, sensor pipelines, irrigation/vision/planning services, the API and worker apps, and the student projects and experiments that build on top of From 3dcc94800265b74e4d41a365779ae89fe8debeb2 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Thu, 25 Jun 2026 09:46:02 +0200 Subject: [PATCH 07/10] feat: implement session persistence for UI with history management --- .gitignore | 3 + apps/ui/src/twfarmbot_ui/app.py | 271 ++++++++++++++++++++++++++++ apps/ui/src/twfarmbot_ui/history.py | 124 +++++++++++++ tests/test_history.py | 106 +++++++++++ 4 files changed, 504 insertions(+) create mode 100644 apps/ui/src/twfarmbot_ui/history.py create mode 100644 tests/test_history.py diff --git a/.gitignore b/.gitignore index bf52364..15fd32e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ build/ .DS_Store .idea/ .vscode/ + +# Runtime UI session data +data/ui_sessions/ diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index ff46384..7e13619 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -26,6 +26,7 @@ from twfarmbot_core.actions import summarize_action from twfarmbot_ui.client import ApiClient, ApiResult +from twfarmbot_ui import history # ── config ──────────────────────────────────────────────────────────────────── @@ -147,10 +148,12 @@ def _render_proposed_actions_inline( results = _execute_proposed_actions(actions, message, wait=True) message["approved"] = True message["content"] += "\n\n" + _format_execution_results(results) + _persist_session() st.rerun() if reject_col.button("✕ Reject", key=f"reject_{idx}", use_container_width=True): message["rejected"] = True message["content"] += "\n\n❌ Cancelled." + _persist_session() st.rerun() @@ -295,6 +298,7 @@ def _refresh_telemetry(client: ApiClient) -> None: "Camera", "I/O", "Assistant", + "History", "Diagnostics", "Settings", ] @@ -1472,10 +1476,13 @@ def _render_assistant() -> None: with clear_col: if st.button("Clear chat", use_container_width=True): st.session_state["assistant_messages"] = [] + _persist_session() st.rerun() + _render_session_controls() selected_model = _render_model_picker() st.session_state["assistant_selected_model"] = selected_model _render_chat() + _persist_session() def _render_chat() -> None: @@ -1556,9 +1563,11 @@ def _render_chat() -> None: else: last_assistant["rejected"] = True last_assistant["content"] += "\n\n❌ Cancelled." + _persist_session() st.rerun() else: st.toast("No pending proposal to approve or reject.", icon="⚠️") + _persist_session() st.rerun() st.session_state["assistant_messages"].append( @@ -1724,6 +1733,7 @@ def _close_text_segment() -> None: "images": photo_images, } ) + _persist_session() st.rerun() @@ -1833,6 +1843,163 @@ def _format_execution_results(results: list[dict[str, Any]]) -> str: return "\n".join(lines) +# ── session persistence ─────────────────────────────────────────────────────── + + +def _has_session_state() -> bool: + """Return True if any chat/plan state has been initialised.""" + return ( + "assistant_messages" in st.session_state + or "assistant_plan_response" in st.session_state + or "executed_plans" in st.session_state + ) + + +def _is_session_empty() -> bool: + """Return True if the current session has nothing worth saving.""" + messages = st.session_state.get("assistant_messages") or [] + plan_response = st.session_state.get("assistant_plan_response") + executed = st.session_state.get("executed_plans") or [] + return not messages and not plan_response and not executed + + +def _restore_session() -> None: + """Load the latest or URL-specified session on first app load.""" + if _has_session_state(): + return + + session_id = st.query_params.get("session") + if isinstance(session_id, list): + session_id = session_id[0] if session_id else None + + snapshot: dict[str, Any] | None = None + if session_id: + snapshot = history.load_session(session_id) + if snapshot is None: + sessions = history.list_sessions(limit=1) + if sessions: + snapshot = history.load_session(sessions[0]["session_id"]) + + if snapshot is None: + snapshot = history.empty_snapshot() + + st.session_state["assistant_session_id"] = snapshot["session_id"] + st.session_state["assistant_session_label"] = snapshot.get("label") + st.session_state["assistant_messages"] = snapshot.get("assistant_messages", []) + st.session_state["assistant_plan_request"] = snapshot.get( + "assistant_plan_request", "" + ) + st.session_state["assistant_plan_response"] = snapshot.get( + "assistant_plan_response" + ) + st.session_state["assistant_plan_status"] = snapshot.get( + "assistant_plan_status" + ) + st.session_state["assistant_selected_model"] = snapshot.get( + "assistant_selected_model" + ) + st.session_state["executed_plans"] = snapshot.get("executed_plans", []) + + +def _persist_session() -> None: + """Save the current chat/plan state to disk.""" + if _is_session_empty(): + return + snapshot = history.empty_snapshot( + session_id=st.session_state.get("assistant_session_id") + ) + snapshot["label"] = st.session_state.get("assistant_session_label") + snapshot["created_at"] = st.session_state.get( + "assistant_session_created_at", snapshot["created_at"] + ) + snapshot["assistant_messages"] = st.session_state.get("assistant_messages", []) + snapshot["assistant_plan_request"] = st.session_state.get( + "assistant_plan_request", "" + ) + snapshot["assistant_plan_response"] = st.session_state.get( + "assistant_plan_response" + ) + snapshot["assistant_plan_status"] = st.session_state.get("assistant_plan_status") + snapshot["assistant_selected_model"] = st.session_state.get( + "assistant_selected_model" + ) + snapshot["executed_plans"] = st.session_state.get("executed_plans", []) + history.save_session(snapshot) + + +def _render_session_controls() -> None: + """Render session management widgets inside the Assistant tab.""" + with st.expander("🗂️ Session", expanded=False): + current_label = st.text_input( + "Session label", + value=st.session_state.get("assistant_session_label") or "", + key="assistant_session_label_input", + placeholder="e.g. watering experiment", + ) + st.session_state["assistant_session_label"] = ( + current_label.strip() or None + ) + + new_col, save_col = st.columns([1, 1]) + if new_col.button("New session", use_container_width=True): + _persist_session() + new_id = history.new_session_id() + st.session_state["assistant_session_id"] = new_id + st.session_state["assistant_session_label"] = None + st.session_state["assistant_messages"] = [] + st.session_state["assistant_plan_request"] = "" + st.session_state["assistant_plan_response"] = None + st.session_state["assistant_plan_status"] = None + st.session_state["executed_plans"] = [] + st.query_params.pop("session", None) + st.rerun() + if save_col.button("Save now", use_container_width=True): + _persist_session() + st.toast("Session saved", icon="💾") + + sessions = history.list_sessions(limit=20) + if sessions: + st.divider() + st.markdown("**Previous sessions**") + for sess in sessions: + if sess["session_id"] == st.session_state.get("assistant_session_id"): + continue + label = sess["label"] or sess["session_id"] + preview = sess["preview"] + c1, c2, c3 = st.columns([3, 1, 1]) + c1.caption(f"{label}" + (f" · {preview}" if preview else "")) + if c2.button("Load", key=f"load_sess_{sess['session_id']}", use_container_width=True): + snapshot = history.load_session(sess["session_id"]) + if snapshot is None: + st.error("Session not found") + continue + st.session_state["assistant_session_id"] = snapshot["session_id"] + st.session_state["assistant_session_label"] = snapshot.get("label") + st.session_state["assistant_messages"] = snapshot.get( + "assistant_messages", [] + ) + st.session_state["assistant_plan_request"] = snapshot.get( + "assistant_plan_request", "" + ) + st.session_state["assistant_plan_response"] = snapshot.get( + "assistant_plan_response" + ) + st.session_state["assistant_plan_status"] = snapshot.get( + "assistant_plan_status" + ) + st.session_state["assistant_selected_model"] = snapshot.get( + "assistant_selected_model" + ) + st.session_state["executed_plans"] = snapshot.get( + "executed_plans", [] + ) + st.query_params["session"] = snapshot["session_id"] + st.rerun() + if c3.button("🗑", key=f"del_sess_{sess['session_id']}", use_container_width=True): + history.delete_session(sess["session_id"]) + st.rerun() + + def _render_plan() -> None: st.caption( "Describe a task. The LLM builds a step-by-step plan; review it before running." @@ -1894,6 +2061,7 @@ def _render_plan() -> None: r.body if r.ok else {"error": r.body} ) st.session_state["assistant_plan_status"] = r.code + _persist_session() response = st.session_state.get("assistant_plan_response") status = st.session_state.get("assistant_plan_status") @@ -1934,17 +2102,26 @@ def _render_plan() -> None: if clear_col.button("Clear", use_container_width=True): st.session_state["assistant_plan_response"] = None st.session_state["assistant_plan_status"] = None + _persist_session() st.rerun() if run_col.button("Run plan", type="primary", use_container_width=True): queued = 0 failed = 0 + action_results: list[dict[str, Any]] = [] for action in actions: r = client.request( "POST", "/actions", json={"kind": action["kind"], "params": action.get("params", {})}, ) + action_results.append( + { + "kind": action["kind"], + "ok": r.ok, + "detail": r.error_message() if not r.ok else None, + } + ) if r.ok: queued += 1 st.toast(f"Queued {action['kind']}", icon="➡️") @@ -1955,11 +2132,101 @@ def _render_plan() -> None: st.success(f"Plan queued · {queued} action(s)") else: st.warning(f"Plan partially queued · {queued} ok, {failed} failed") + executed = st.session_state.get("executed_plans") or [] + executed.append( + { + "request": response.get("request", ""), + "actions": actions, + "results": action_results, + "queued_at": datetime.now().isoformat(), + "status": "ok" if failed == 0 else ("partial" if queued > 0 else "failed"), + } + ) + st.session_state["executed_plans"] = executed st.session_state["assistant_plan_response"] = None st.session_state["assistant_plan_status"] = None + _persist_session() st.rerun() +def _render_history() -> None: + st.markdown( + '
TWFarmBot · UAS Technikum Wien
', + unsafe_allow_html=True, + ) + st.markdown("# History") + + sessions = history.list_sessions(limit=50) + if not sessions: + st.info("No saved sessions yet. Chat and plans are saved automatically.") + return + + st.markdown("## Chat sessions") + for sess in sessions: + label = sess["label"] or sess["session_id"] + updated = sess["updated_at"][:19].replace("T", " ") if sess["updated_at"] else "" + c1, c2 = st.columns([4, 1]) + with c1: + st.markdown(f"**{label}**") + st.caption( + f"Updated {updated}" + (f" · {sess['preview']}" if sess["preview"] else "") + ) + if c2.button("Load", key=f"hist_load_{sess['session_id']}", use_container_width=True): + snapshot = history.load_session(sess["session_id"]) + if snapshot is None: + st.error("Session not found") + else: + st.session_state["assistant_session_id"] = snapshot["session_id"] + st.session_state["assistant_session_label"] = snapshot.get("label") + st.session_state["assistant_messages"] = snapshot.get( + "assistant_messages", [] + ) + st.session_state["assistant_plan_request"] = snapshot.get( + "assistant_plan_request", "" + ) + st.session_state["assistant_plan_response"] = snapshot.get( + "assistant_plan_response" + ) + st.session_state["assistant_plan_status"] = snapshot.get( + "assistant_plan_status" + ) + st.session_state["assistant_selected_model"] = snapshot.get( + "assistant_selected_model" + ) + st.session_state["executed_plans"] = snapshot.get("executed_plans", []) + st.query_params["session"] = snapshot["session_id"] + st.rerun() + + executed = st.session_state.get("executed_plans") or [] + if executed: + st.markdown("## Executed plans") + for idx, plan in enumerate(reversed(executed), start=1): + with st.container(border=True): + queued_at = plan.get("queued_at", "") + ts = queued_at[:19].replace("T", " ") if queued_at else "" + status = plan.get("status", "unknown") + status_emoji = {"ok": "✅", "partial": "⚠️", "failed": "❌"}.get( + status, "❓" + ) + st.markdown( + f"{status_emoji} **Plan {idx}** · {plan.get('request', '')}" + ) + st.caption(f"{ts} · {len(plan.get('actions', []))} action(s) · {status}") + with st.expander("Actions"): + for action in plan.get("actions", []): + st.markdown(f"• {_action_summary(action)}") + results = plan.get("results", []) + if results: + with st.expander("Results"): + for res in results: + icon = "✅" if res.get("ok") else "❌" + detail = res.get("detail") + line = f"{icon} {_action_summary({'kind': res['kind'], 'params': {}})}" + if detail: + line += f" — {detail}" + st.markdown(line) + + def _render_diagnostics() -> None: st.markdown( '
TWFarmBot · UAS Technikum Wien
', @@ -2080,7 +2347,11 @@ def _render_settings() -> None: "Camera": _render_camera, "I/O": _render_io, "Assistant": _render_assistant, + "History": _render_history, "Diagnostics": _render_diagnostics, "Settings": _render_settings, } + +# Restore the latest or URL-specified chat/plan session on first load. +_restore_session() renderers[tab]() diff --git a/apps/ui/src/twfarmbot_ui/history.py b/apps/ui/src/twfarmbot_ui/history.py new file mode 100644 index 0000000..7631737 --- /dev/null +++ b/apps/ui/src/twfarmbot_ui/history.py @@ -0,0 +1,124 @@ +"""Persistence for Streamlit UI session state. + +Chat history, plan previews, and executed plans are saved as JSON files so +they survive page reloads. Storage is local and intended for a single-user +research UI; concurrent writes to the same session file are last-write-wins. +""" + +from __future__ import annotations + +import json +import os +import secrets +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def session_data_dir() -> Path: + """Return the directory used to store session JSON files.""" + path = Path( + os.getenv("TWFB_UI_DATA_DIR", Path.cwd() / "data" / "ui_sessions") + ) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _session_path(session_id: str) -> Path: + return session_data_dir() / f"{session_id}.json" + + +def new_session_id() -> str: + """Generate a new session id based on an ISO timestamp plus a random suffix.""" + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") + suffix = secrets.token_hex(4) + return f"{stamp}-{suffix}" + + +def save_session(snapshot: dict[str, Any]) -> Path: + """Write a session snapshot to disk. + + ``snapshot`` must contain a ``session_id`` key. The ``updated_at`` field + is refreshed automatically. + """ + session_id = snapshot["session_id"] + snapshot["updated_at"] = _utc_now() + path = _session_path(session_id) + path.write_text(json.dumps(snapshot, indent=2, default=str), encoding="utf-8") + return path + + +def load_session(session_id: str) -> dict[str, Any] | None: + """Load a session snapshot by id, or return None if it does not exist.""" + path = _session_path(session_id) + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + +def delete_session(session_id: str) -> bool: + """Delete a session file. Returns True if it existed and was removed.""" + path = _session_path(session_id) + if path.exists(): + path.unlink() + return True + return False + + +def list_sessions(limit: int = 50) -> list[dict[str, Any]]: + """Return metadata for saved sessions, newest first. + + Each item contains ``session_id``, ``label``, ``created_at``, + ``updated_at``, and a ``preview`` snippet of the latest user message. + """ + sessions: list[dict[str, Any]] = [] + data_dir = session_data_dir() + for path in data_dir.glob("*.json"): + try: + snapshot = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + session_id = snapshot.get("session_id") + if not session_id: + continue + messages = snapshot.get("assistant_messages") or [] + preview = "" + for msg in reversed(messages): + if msg.get("role") == "user": + preview = str(msg.get("content", ""))[:80] + break + sessions.append( + { + "session_id": session_id, + "label": snapshot.get("label") or None, + "created_at": snapshot.get("created_at", ""), + "updated_at": snapshot.get("updated_at", ""), + "preview": preview, + } + ) + sessions.sort(key=lambda s: s["updated_at"], reverse=True) + return sessions[:limit] + + +def empty_snapshot(session_id: str | None = None) -> dict[str, Any]: + """Return a fresh, empty session snapshot.""" + now = _utc_now() + return { + "session_id": session_id or new_session_id(), + "label": None, + "created_at": now, + "updated_at": now, + "assistant_messages": [], + "assistant_plan_request": "", + "assistant_plan_response": None, + "assistant_plan_status": None, + "assistant_selected_model": None, + "executed_plans": [], + } diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..d956543 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,106 @@ +"""Tests for the UI session persistence helper.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from twfarmbot_ui import history + + +@pytest.fixture +def tmp_history_dir(tmp_path: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch): + """Redirect session storage to a temp directory for each test.""" + monkeypatch.setattr(history, "session_data_dir", lambda: tmp_path) + return tmp_path + + +def test_empty_snapshot_has_required_keys() -> None: + snap = history.empty_snapshot("test-id") + assert snap["session_id"] == "test-id" + assert snap["assistant_messages"] == [] + assert snap["executed_plans"] == [] + assert "created_at" in snap + assert "updated_at" in snap + + +def test_save_and_load_session_round_trip(tmp_history_dir: Any) -> None: + snap = history.empty_snapshot("sess-1") + snap["label"] = "my session" + snap["assistant_messages"] = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + snap["executed_plans"] = [ + { + "request": "water bed", + "actions": [{"kind": "water", "params": {"seconds": 10}}], + "results": [{"kind": "water", "ok": True, "detail": None}], + "queued_at": "2026-06-24T12:00:00", + "status": "ok", + } + ] + history.save_session(snap) + + loaded = history.load_session("sess-1") + assert loaded is not None + assert loaded["label"] == "my session" + assert loaded["assistant_messages"][0]["content"] == "hello" + assert len(loaded["executed_plans"]) == 1 + assert loaded["executed_plans"][0]["status"] == "ok" + + +def test_save_session_updates_updated_at(tmp_history_dir: Any) -> None: + snap = history.empty_snapshot("sess-2") + original_updated = snap["updated_at"] + history.save_session(snap) + loaded = history.load_session("sess-2") + assert loaded is not None + assert loaded["updated_at"] >= original_updated + + +def test_load_missing_session_returns_none(tmp_history_dir: Any) -> None: + assert history.load_session("does-not-exist") is None + + +def test_delete_session(tmp_history_dir: Any) -> None: + snap = history.empty_snapshot("sess-del") + history.save_session(snap) + assert history.delete_session("sess-del") is True + assert history.load_session("sess-del") is None + assert history.delete_session("sess-del") is False + + +def test_list_sessions_orders_by_updated_desc(tmp_history_dir: Any) -> None: + old = history.empty_snapshot("old-sess") + history.save_session(old) + + new = history.empty_snapshot("new-sess") + new["assistant_messages"] = [{"role": "user", "content": "latest"}] + history.save_session(new) + + sessions = history.list_sessions() + ids = [s["session_id"] for s in sessions] + assert ids == ["new-sess", "old-sess"] + + +def test_list_sessions_includes_preview(tmp_history_dir: Any) -> None: + snap = history.empty_snapshot("preview-sess") + snap["assistant_messages"] = [ + {"role": "assistant", "content": "hi"}, + {"role": "user", "content": "water the tomatoes please"}, + ] + history.save_session(snap) + + sessions = history.list_sessions() + assert len(sessions) == 1 + assert sessions[0]["preview"] == "water the tomatoes please" + + +def test_list_sessions_respects_limit(tmp_history_dir: Any) -> None: + for i in range(5): + snap = history.empty_snapshot(f"sess-{i}") + history.save_session(snap) + assert len(history.list_sessions(limit=2)) == 2 + assert len(history.list_sessions(limit=10)) == 5 From d201b437ff3b7d22c828e904b5249d726fdd1059 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Thu, 25 Jun 2026 10:59:39 +0200 Subject: [PATCH 08/10] fix: update image rendering for better responsiveness and adjust sensor/actuator UI elements --- apps/ui/src/twfarmbot_ui/app.py | 155 ++++++++++++++++++++------------ configs/dev.yaml | 9 +- 2 files changed, 104 insertions(+), 60 deletions(-) diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index 7e13619..7ac51b7 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -116,20 +116,20 @@ def _render_tool_call( return if name == "analyze_image" and result.get("image_url"): - st.image(result["image_url"], width=400) + st.image(result["image_url"], use_container_width=True) elif name == "estimate_traversability" and result.get("image_url"): - st.image(result["image_url"], width=400) + st.image(result["image_url"], use_container_width=True) elif name in {"segment_image", "visualize_image_features"} and result.get( "image_urls" ): cols = st.columns(min(len(result["image_urls"]), 3)) for idx, url in enumerate(result["image_urls"]): - cols[idx % len(cols)].image(url, width=300) + cols[idx % len(cols)].image(url, use_container_width=True) for label_text in result.get("labels", []): st.caption(label_text) elif result.get("image_url"): # Fallback for any other tool that returns a single image (e.g. take_photo). - st.image(result["image_url"], width=400) + st.image(result["image_url"], use_container_width=True) def _render_proposed_actions_inline( @@ -441,6 +441,18 @@ def _do_pin_pulse( .card-label { font-size: .66rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; opacity: .5; } .card-value { font-size: 1.3rem; font-weight: 650; margin-top: .4rem; } + .sensor-value { + background: var(--secondary-background-color); + border: 1px solid rgba(128,128,128,0.10); + border-radius: 9px; + padding: .55rem .75rem; + font-size: 1.25rem; + font-weight: 650; + min-height: 2.2rem; + display: flex; + align-items: center; + margin-top: .4rem; + } .empty { opacity: .4; font-size: .8rem; } div[data-testid="stMetric"] { background: var(--secondary-background-color); @@ -467,13 +479,15 @@ def _do_pin_pulse( .st-key-analysis_source img, .st-key-analysis_processed img { width: 100% !important; - height: 320px !important; + max-height: 70vh !important; object-fit: contain !important; background: var(--secondary-background-color); border-radius: 9px; } - [data-testid="stChatMessage"] img { - max-width: 480px !important; + [data-testid="stChatMessage"] img, + [data-testid="stImage"] img { + max-width: 100% !important; + max-height: 70vh !important; border-radius: 9px; } [data-testid="stChatMessage"] { @@ -1103,34 +1117,44 @@ def _render_io() -> None: # ── Sensors ─────────────────────────────────────────────────────────────── sensors = [p for p in named if p.get("kind") == "sensor"] if sensors: - st.markdown("## Sensors") + st.markdown("### 🔍 Sensors") cols = st.columns(min(3, len(sensors))) for i, s in enumerate(sensors): with cols[i]: - st.caption(f"{s['label']} · pin {s['pin']} · {s.get('mode', 'analog')}") - if st.button("Read", key=f"sensor_{i}", use_container_width=True): - r = client.request( - "GET", - f"/pin/{s['pin']}", - params={"mode": s.get("mode", "analog")}, + with st.container(border=True, height=170): + mode = s.get("mode", "analog") + st.markdown( + f"**{s['label']}** {mode}", + unsafe_allow_html=True, ) - st.session_state[f"sv_{s['pin']}"] = ( - r.body.get("value") if r.ok else "—" + st.caption(f"pin {s['pin']}") + if st.button("Read", key=f"sensor_{i}", use_container_width=True): + r = client.request( + "GET", + f"/pin/{s['pin']}", + params={"mode": mode}, + ) + st.session_state[f"sv_{s['pin']}"] = ( + r.body.get("value") if r.ok else "—" + ) + sensor_value = st.session_state.get(f"sv_{s['pin']}", "—") + st.markdown( + f"
{sensor_value}
", + unsafe_allow_html=True, ) - st.metric("Value", st.session_state.get(f"sv_{s['pin']}", "—")) elif named: st.info("No sensor pins configured.") st.divider() # ── Actuators ───────────────────────────────────────────────────────────── - st.markdown("## Actuators") + st.markdown("### ⚡ Actuators") a, b = st.columns(2) with a: - st.markdown("**Irrigation**") - with st.form("water"): - secs = st.number_input("Seconds", 0.1, 300.0, 2.0, 0.5) - if st.form_submit_button("Water", use_container_width=True): + with st.container(border=True, height=320): + st.markdown("**💧 Irrigation**") + secs = st.number_input("Seconds", 0.1, 300.0, 2.0, 0.5, key="water_secs") + if st.button("Water", use_container_width=True, type="primary"): r = client.request( "POST", "/actions", @@ -1140,43 +1164,62 @@ def _render_io() -> None: st.success("Queued") else: st.error(r.error_message()) + st.caption("Runs the pump for the selected duration.") with b: - st.markdown("**Peripheral control**") - outputs = [p for p in named if p.get("kind") != "sensor"] - if not outputs: - st.info("No output pins configured.") - else: - sel = st.selectbox( - "Output", - outputs, - format_func=lambda p: f"{p['label']} · pin {p['pin']}", - ) - if sel: - mode = sel.get("mode", "digital") - high_mode = st.segmented_control( - "HIGH mode", ["Timed", "Keep on"], default="Timed" + with st.container(border=True, height=320): + st.markdown("**🔌 Peripheral control**") + outputs = [p for p in named if p.get("kind") != "sensor"] + if not outputs: + st.info("No output pins configured.") + else: + sel = st.selectbox( + "Output", + outputs, + format_func=lambda p: f"{p['label']} · pin {p['pin']}", + label_visibility="collapsed", ) - if high_mode == "Timed": - high_secs = st.number_input( - "Seconds to stay HIGH", - 0.1, - 300.0, - 2.0, - 0.5, - key="high_secs", + if sel: + mode = sel.get("mode", "digital") + st.markdown( + f"{mode}", + unsafe_allow_html=True, ) - else: - high_secs = None - - off, on = st.columns(2) - if off.button("Set LOW", use_container_width=True): - _do_pin_write(client, sel["pin"], 0, mode) - if on.button("Set HIGH", use_container_width=True): - if high_mode == "Timed" and high_secs is not None: - _do_pin_pulse(client, sel["pin"], high_secs, mode) + + if mode == "analog": + val_col, btn_col = st.columns([4, 1]) + with val_col: + analog_value = st.slider( + "PWM value", + min_value=0, + max_value=255, + value=0, + key=f"analog_value_{sel['pin']}", + ) + with btn_col: + st.markdown("
", unsafe_allow_html=True) + if st.button("Apply", use_container_width=True): + _do_pin_write(client, sel["pin"], analog_value, mode) else: - _do_pin_write(client, sel["pin"], 1, mode) + pulse = st.toggle("Timed pulse", value=True) + if pulse: + pulse_secs = st.number_input( + "Seconds", + 0.1, + 300.0, + 2.0, + 0.5, + key=f"pulse_secs_{sel['pin']}", + ) + + off, on = st.columns(2) + if off.button("⏻ OFF", use_container_width=True): + _do_pin_write(client, sel["pin"], 0, mode) + if on.button("⏻ ON", use_container_width=True, type="primary"): + if pulse and pulse_secs is not None: + _do_pin_pulse(client, sel["pin"], pulse_secs, mode) + else: + _do_pin_write(client, sel["pin"], 1, mode) def _render_camera() -> None: @@ -1224,7 +1267,7 @@ def _render_camera() -> None: img_col, ctrl_col = st.columns([1.6, 1]) with img_col: - st.image(selected.get("attachment_url"), width=500) + st.image(selected.get("attachment_url"), use_container_width=True) with ctrl_col: st.markdown("**AI analysis**") @@ -1375,7 +1418,7 @@ def _render_camera() -> None: result_cols = st.columns(len(result["paths"])) for idx, (path, caption) in enumerate(zip(result["paths"], result["captions"])): with result_cols[idx]: - st.image(path, caption=caption, width=280) + st.image(path, caption=caption, use_container_width=True) st.markdown("**Raw output**") if result.get("class_scores"): diff --git a/configs/dev.yaml b/configs/dev.yaml index 8f734cb..df11856 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -7,14 +7,14 @@ log_level: INFO # the environment, or reference a different env var via api_key_env. planning: base_url: https://openrouter.ai/api/v1 # OpenAI-compatible endpoint - model: openai/gpt-oss-safeguard-20b # any model the endpoint exposes + model: openai/gpt-4o-mini # tool-calling model api_key_env: PLANNING_LLM_API_KEY # name of the env var that holds the secret timeout_s: 120 # raise this for slower models temperature: 0.0 - # Provider-specific request body extensions. For DeepSeek V4 reasoning on - # OpenRouter use: extra_body: {reasoning: {effort: low}} + # Provider-specific request body extensions. Only needed for reasoning models. + # For DeepSeek V4 on OpenRouter use: extra_body: {reasoning: {effort: low}} # For native DeepSeek use: extra_body: {thinking: {type: enabled}} - extra_body: {reasoning: {effort: low}} + extra_body: {} # Optional W&B Weave project for tracing. Set WEAVE_PROJECT or this field # to enable model/tool-call/token tracing. weave_project: "" @@ -46,6 +46,7 @@ watering: pins: - {label: "Pump", pin: 7, mode: digital, kind: valve, group: "Water"} - {label: "Water solenoid", pin: 8, mode: digital, kind: valve, group: "Water"} +- {label: "Cutting end effector", pin: 4, mode: analog, kind: io, group: "Tools"} - {label: "Tool servo", pin: 10, mode: digital, kind: servo, group: "Tools"} - {label: "Soil sensor", pin: 14, mode: analog, kind: sensor, group: "Sensors"} - {label: "Light sensor", pin: 15, mode: analog, kind: sensor, group: "Sensors"} From c8ea5253720fcbd85429119b20ad05222fbaca29 Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Thu, 25 Jun 2026 11:23:49 +0200 Subject: [PATCH 09/10] feat: enhance pin handling with configurable modes and presets in UI and backend --- .../src/twfarmbot_api_server/handlers/pin.py | 19 +++++++++- apps/ui/src/twfarmbot_ui/app.py | 18 +++++++-- configs/dev.yaml | 5 ++- .../harness/context_builder.py | 37 +++++++++++++++++++ .../planning_service/tools.py | 24 ++++++++++-- 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/pin.py b/apps/api_server/src/twfarmbot_api_server/handlers/pin.py index c0c3679..b633c87 100644 --- a/apps/api_server/src/twfarmbot_api_server/handlers/pin.py +++ b/apps/api_server/src/twfarmbot_api_server/handlers/pin.py @@ -2,14 +2,29 @@ from __future__ import annotations +from twfarmbot_core.config import load_yaml_config from twfarmbot_core.domain import Action from watering_service.backends import farmbot +def _pin_mode(pin: int, requested: str | None) -> str: + """Return the requested pin mode, falling back to the configured mode.""" + if requested: + return str(requested) + try: + pins = load_yaml_config().get("pins", []) or [] + for p in pins: + if int(p.get("pin", -1)) == pin: + return str(p.get("mode", "digital")) + except Exception: # noqa: BLE001 + pass + return "digital" + + def handle_read_pin(action: Action) -> Action: pin = int(action.params["pin"]) - mode = str(action.params.get("mode", "digital")) + mode = _pin_mode(pin, action.params.get("mode")) value = farmbot.backend.read_pin(pin, mode) return Action(kind=action.kind, params={ "pin": pin, "mode": mode, "value": value @@ -19,7 +34,7 @@ def handle_read_pin(action: Action) -> Action: def handle_write_pin(action: Action) -> Action: pin = int(action.params["pin"]) value = int(action.params["value"]) - mode = str(action.params.get("mode", "digital")) + mode = _pin_mode(pin, action.params.get("mode")) seconds = action.params.get("seconds") duration = None if value == 1 and seconds is not None: diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index 7ac51b7..1f38306 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -1121,7 +1121,7 @@ def _render_io() -> None: cols = st.columns(min(3, len(sensors))) for i, s in enumerate(sensors): with cols[i]: - with st.container(border=True, height=170): + with st.container(border=True): mode = s.get("mode", "analog") st.markdown( f"**{s['label']}** {mode}", @@ -1151,7 +1151,7 @@ def _render_io() -> None: st.markdown("### ⚡ Actuators") a, b = st.columns(2) with a: - with st.container(border=True, height=320): + with st.container(border=True): st.markdown("**💧 Irrigation**") secs = st.number_input("Seconds", 0.1, 300.0, 2.0, 0.5, key="water_secs") if st.button("Water", use_container_width=True, type="primary"): @@ -1167,7 +1167,7 @@ def _render_io() -> None: st.caption("Runs the pump for the selected duration.") with b: - with st.container(border=True, height=320): + with st.container(border=True): st.markdown("**🔌 Peripheral control**") outputs = [p for p in named if p.get("kind") != "sensor"] if not outputs: @@ -1187,6 +1187,18 @@ def _render_io() -> None: ) if mode == "analog": + presets = sel.get("presets") or {} + if presets: + st.caption("Presets") + preset_cols = st.columns(len(presets)) + for idx, (pval, plabel) in enumerate(sorted(presets.items(), key=lambda x: int(x[0]))): + if preset_cols[idx].button( + f"{plabel} ({pval})", + use_container_width=True, + key=f"preset_{sel['pin']}_{pval}", + ): + _do_pin_write(client, sel["pin"], int(pval), mode) + val_col, btn_col = st.columns([4, 1]) with val_col: analog_value = st.slider( diff --git a/configs/dev.yaml b/configs/dev.yaml index df11856..3702fbc 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -38,15 +38,16 @@ safety: watering: pump_pin: 7 -# Named pins for the UI: label → {pin, mode, kind, group}. +# Named pins for the UI: label → {pin, mode, kind, group, presets}. # kind: "valve" | "servo" | "sensor" | "io" # group: shown as a column header in the pin grid +# presets (optional): value → label map for analog pins, e.g. {0: "Off", 120: "Cut"} # Every pin number must be unique; a single GPIO can't drive a valve and # read a sensor at the same time. pins: - {label: "Pump", pin: 7, mode: digital, kind: valve, group: "Water"} - {label: "Water solenoid", pin: 8, mode: digital, kind: valve, group: "Water"} -- {label: "Cutting end effector", pin: 4, mode: analog, kind: io, group: "Tools"} +- {label: "Cutting end effector", pin: 4, mode: analog, kind: io, group: "Tools", presets: {0: "Off", 120: "Cut", 255: "Max"}} - {label: "Tool servo", pin: 10, mode: digital, kind: servo, group: "Tools"} - {label: "Soil sensor", pin: 14, mode: analog, kind: sensor, group: "Sensors"} - {label: "Light sensor", pin: 15, mode: analog, kind: sensor, group: "Sensors"} diff --git a/services/planning_service/planning_service/harness/context_builder.py b/services/planning_service/planning_service/harness/context_builder.py index ab35ff4..4176bb1 100644 --- a/services/planning_service/planning_service/harness/context_builder.py +++ b/services/planning_service/planning_service/harness/context_builder.py @@ -11,6 +11,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from spatial_service import format_world_context +from twfarmbot_core.config import load_yaml_config from .tool_policy import ToolCategory from .tool_registry import ToolRegistry @@ -112,6 +113,7 @@ def __init__( def chat_system_prompt(self) -> str: parts = [_CHAT_HEADER] parts.append(self._render_tool_section()) + parts.append(_format_pin_context()) parts.append(_CHAT_FOOTER) if self._propose_only: parts.append(_PROPOSE_ONLY_APPENDIX) @@ -125,6 +127,7 @@ def chat_system_prompt(self) -> str: def planner_system_prompt(self) -> str: parts = [_PLANNER_HEADER] parts.append(self._render_tool_section(for_planner=True)) + parts.append(_format_pin_context()) parts.append(_PLANNER_FOOTER) return "\n".join(parts) @@ -225,3 +228,37 @@ def _render_tool_section(self, for_planner: bool = False) -> str: lines.append(f"- `{d.name}` — {d.policy.description}{approval}") return "\n".join(lines) + + +def _format_pin_context() -> str: + """Load named pins from config and format them for the system prompt.""" + try: + pins = load_yaml_config().get("pins", []) or [] + except Exception: # noqa: BLE001 + pins = [] + if not pins: + return "" + lines = ["\nConfigured GPIO pins (single source of truth):"] + for p in pins: + label = p.get("label", "unknown") + pin = p.get("pin", "?") + mode = p.get("mode", "digital") + kind = p.get("kind", "io") + group = p.get("group", "") + group_text = f" · {group}" if group else "" + presets = p.get("presets") or {} + preset_text = "" + if presets: + preset_items = ", ".join( + f"{v}={lbl}" for v, lbl in sorted(presets.items(), key=lambda x: int(x[0])) + ) + preset_text = f" · presets: {preset_items}" + lines.append( + f"- pin {pin} · {label} · mode={mode} · kind={kind}{group_text}{preset_text}" + ) + lines.append( + "When calling read_pin or write_pin, use the configured mode for the pin " + "unless the user explicitly asks for a different mode. For analog pins, " + "use the named preset values when the user refers to them." + ) + return "\n".join(lines) diff --git a/services/planning_service/planning_service/tools.py b/services/planning_service/planning_service/tools.py index 0c6553e..687fe21 100644 --- a/services/planning_service/planning_service/tools.py +++ b/services/planning_service/planning_service/tools.py @@ -39,13 +39,31 @@ class FindHomeArgs(BaseModel): class ReadPinArgs(BaseModel): pin: int = Field(..., description="GPIO pin number to read.") - mode: str = Field(default="digital", description="'digital' or 'analog'.") + mode: str = Field( + default="digital", + description=( + "'digital' or 'analog'. If omitted, default to the mode configured " + "for this pin in the system config." + ), + ) class WritePinArgs(BaseModel): pin: int = Field(..., description="GPIO pin number to write.") - value: int = Field(..., description="0 or 1.") - mode: str = Field(default="digital", description="'digital' or 'analog'.") + value: int = Field( + ..., + description=( + "Value to write. For digital pins use 0 or 1. For analog pins use " + "0..255 (PWM)." + ), + ) + mode: str = Field( + default="digital", + description=( + "'digital' or 'analog'. If omitted, default to the mode configured " + "for this pin in the system config." + ), + ) class MountToolArgs(BaseModel): From b4be937cb9cb70cba46f2189199937aa538ba37a Mon Sep 17 00:00:00 2001 From: DavidSeyserGit Date: Thu, 25 Jun 2026 11:26:12 +0200 Subject: [PATCH 10/10] changed default model --- configs/dev.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/dev.yaml b/configs/dev.yaml index 3702fbc..7320b0b 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -7,7 +7,7 @@ log_level: INFO # the environment, or reference a different env var via api_key_env. planning: base_url: https://openrouter.ai/api/v1 # OpenAI-compatible endpoint - model: openai/gpt-4o-mini # tool-calling model + model: ibm-granite/granite-4.1-8b # tool-calling model api_key_env: PLANNING_LLM_API_KEY # name of the env var that holds the secret timeout_s: 120 # raise this for slower models temperature: 0.0