diff --git a/.env.example b/.env.example index ebaa03e6..e24f06d8 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ # * Meeseeks Settings # - VERSION: Version of your application (There is no need to change these value) # - ENVMODE: Environment mode of your application (valid options: dev, prod) -VERSION=2.1.0-alpha +VERSION=0.0.7 ENVMODE=dev LOG_LEVEL=DEBUG CACHE_DIR='/path/to/cache/directory' diff --git a/.github/workflows/uv-lock-refresh.yml b/.github/workflows/uv-lock-refresh.yml new file mode 100644 index 00000000..8b2e9fbc --- /dev/null +++ b/.github/workflows/uv-lock-refresh.yml @@ -0,0 +1,35 @@ +name: Refresh uv.lock + +on: + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + push: + paths: + - "pyproject.toml" + - "**/pyproject.toml" + - "uv.lock" + +jobs: + refresh-lock: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Update lockfile + run: uv lock + - name: Create pull request + uses: peter-evans/create-pull-request@v8 + with: + branch: chore/uv-lock-refresh + delete-branch: true + title: "chore(uv): refresh lockfile" + commit-message: "chore(uv): refresh lockfile" + body: | + - Automated uv.lock refresh. + - Runs `uv lock` at repo root. diff --git a/README.md b/README.md index 609ad124..bdd08a2f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/78754e8f-828a-4c54-9e97-29cbeacbc3bc # Intro -Meeseeks is an AI task agent assistant built on a plan-act-observe orchestration loop. It breaks a request into steps, runs tools, and synthesizes a final reply. It keeps a session transcript, compacts long histories, and stores summaries for continuity across longer conversations. +Meeseeks is an AI task agent assistant built on a plan → tool selection → step execution loop. It breaks a request into steps, chooses tools per step, runs them, and synthesizes a final reply. It keeps a session transcript, compacts long histories, and stores summaries for continuity across longer conversations.
Legends (Expand to View) @@ -61,8 +61,9 @@ We are upgrading the API backend to better support a task-orchestration frontend ## Core workflow -- (✅) **Plan → act → observe loop:** Builds a short action plan, executes tools, and replans when needed. -- (✅) **Step-level reflection:** Validates tool outcomes and adjusts step arguments when required. +- (✅) **Plan → select tools → execute:** Builds a short plan, chooses the right tools per step, and executes them. +- (✅) **Step-level reflection:** Validates tool outcomes and adjusts tool inputs when required. +- (✅) **Plan updates:** Emits updated action plans after each step so UIs can refresh the to‑do list. - (✅) **Synthesized replies:** Produces a final answer after tool results are collected and summarized. ## Memory and context management @@ -101,6 +102,7 @@ Optional features that can be installed when needed. - **Inline approvals.** Rich-based approval prompts render with padded, dotted borders and clear after input. - **Unified experience.** Web, API, Home Assistant, and CLI interfaces share the same core engine to reduce duplicated maintenance. - **Shared session runtime.** The API exposes polling endpoints; the CLI runs the same runtime in-process for sync execution, cancellation, and summaries. +- **Event payloads.** `action_plan` steps are `{title, description}`; `tool_result`/`permission` use `tool_id`, `operation`, and `tool_input`. ## Monorepo layout @@ -134,6 +136,10 @@ flowchart LR subgraph Core["Core Orchestration\n(packages/meeseeks_core)"] TaskMaster["orchestrate_session\n(task_master.py)"] Orchestrator["Orchestrator\n(orchestrator.py)"] + Planner["Planner\n(planning.py)"] + ToolSelector["ToolSelector\n(planning.py)"] + StepExecutor["StepExecutor\n(planning.py)"] + PlanUpdater["PlanUpdater\n(planning.py)"] ActionPlanRunner["ActionPlanRunner\n(action_runner.py)"] ContextBuilder["ContextBuilder\n(context.py)"] ActionStep["ActionStep\n(classes.py)"] @@ -176,6 +182,10 @@ flowchart LR SessionRuntime --> RunRegistry TaskMaster --> Orchestrator + Orchestrator --> Planner + Orchestrator --> ToolSelector + Orchestrator --> StepExecutor + Orchestrator --> PlanUpdater Orchestrator --> ActionPlanRunner Orchestrator --> ContextBuilder Orchestrator --> ToolRegistry diff --git a/agents.md b/agents.md index ca9ff92e..471a7e6d 100644 --- a/agents.md +++ b/agents.md @@ -5,7 +5,8 @@ Meeseeks is a multi-agent LLM personal assistant that decomposes user requests i ## Core entry points - `packages/meeseeks_core/src/meeseeks_core/task_master.py`: action planning + task execution loop -- `packages/meeseeks_core/src/meeseeks_core/classes.py`: `ActionStep`, `TaskQueue`, `AbstractTool` contracts +- `packages/meeseeks_core/src/meeseeks_core/classes.py`: `ActionStep` (tool_id/operation/tool_input), `TaskQueue`, `AbstractTool` contracts +- `packages/meeseeks_core/src/meeseeks_core/planning.py`: `Planner`, `ToolSelector`, `StepExecutor`, `PlanUpdater` - `packages/meeseeks_core/src/meeseeks_core/session_runtime.py`: session lifecycle, listing, archiving, and async runs - `packages/meeseeks_core/src/meeseeks_core/session_store.py`: transcript storage, tags, and archive state - `packages/meeseeks_tools/src/meeseeks_tools/`: tool implementations and integration glue @@ -32,7 +33,7 @@ When you need external context (other repos, CI failures, specs, APIs), prefer M ## Engineering principles (project-specific) - KISS and DRY: prefer small, obvious changes; remove redundancy instead of adding layers. - KRY: keep requirements and acceptance criteria in view; do not drift. -- Keep tool contracts stable (`AbstractTool`, `ActionStep`, `TaskQueue`). +- Keep tool contracts stable (`AbstractTool`, `ActionStep`, `TaskQueue`) and the tool field names (`tool_id`, `operation`, `tool_input`). - Favor composition and reuse across interfaces; avoid duplicating core logic. - Add or improve tests for non-trivial behavior; expand coverage when touching core logic or tools. - Use Gitmoji + Conventional Commit format (e.g., `✨ feat: add session summary pass-through`). @@ -44,7 +45,7 @@ When you need external context (other repos, CI failures, specs, APIs), prefer M ## Orchestration insights (transferable) - Separate tool execution from user-facing response: synthesize after tool results, don't dump raw tool output. - Keep the loop explicit: plan -> act -> observe -> decide; re-plan only when needed. -- Make tool inputs schema-aware; prefer structured arguments for MCP tools. +- Make tool inputs schema-aware; prefer structured `tool_input` for MCP tools. - Surface tool activity clearly (permissions, tool IDs, arguments) to reduce user confusion. ## Testing patterns (what worked) @@ -64,7 +65,7 @@ When you need external context (other repos, CI failures, specs, APIs), prefer M ## Linting & formatting - Primary linting uses `ruff` (root + subpackages). Auto-fix with `.venv/bin/ruff check --fix .`. - Type checking uses `mypy`. Run from repo root after installing with `uv`. -- `flake8`, `pylint`, and `autopep8` are still available as dev tools (legacy or ad‑hoc use). +- `flake8`, `pylint`, and `autopep8` are still available as dev tools (optional/ad‑hoc use). - Helper targets: `make lint`, `make lint-fix`, and `make typecheck`. - Pre-commit hooks are defined in `.pre-commit-config.yaml` (install with `make precommit-install`). diff --git a/apps/meeseeks_api/AGENTS.md b/apps/meeseeks_api/AGENTS.md index 1981a0b7..b500f727 100644 --- a/apps/meeseeks_api/AGENTS.md +++ b/apps/meeseeks_api/AGENTS.md @@ -9,11 +9,20 @@ Scope: this file applies to the `apps/meeseeks_api/` package. It captures runtim - `GET /api/sessions` list sessions - `POST /api/sessions/{session_id}/query` enqueue run or core command - `GET /api/sessions/{session_id}/events?after=...` poll events - - `POST /api/query` legacy synchronous endpoint + - `POST /api/query` synchronous endpoint (simple/CLI-compatible) + - `GET /api/tools` list tool registry entries + - `GET /api/notifications` list notifications + - `POST /api/notifications/dismiss` dismiss notifications + - `POST /api/notifications/clear` clear notifications + - `POST /api/sessions/{session_id}/attachments` upload attachments + - `POST /api/sessions/{session_id}/share` create share link + - `POST /api/sessions/{session_id}/export` export session payload + - `GET /api/share/{token}` fetch shared session data - Auth: requires `X-API-KEY` header. Token defaults to `api.master_token` from `configs/app.json` (default: `msk-strong-password`). - Orchestration: uses `meeseeks_core.session_runtime.SessionRuntime` to run sync/async sessions. - Core commands: `/compact`, `/status`, `/terminate` (shared runtime). - Sessions: supports `session_id`, `session_tag`, and `fork_from` (tag or id). Tags are resolved via `SessionStore`. +- Event payloads: `action_plan` steps are `{title, description}`; tool events use `tool_id`, `operation`, `tool_input`. ## Hidden dependencies / assumptions - Uses core logging (`meeseeks_core.common.get_logger`); log level controlled by `runtime.log_level`. diff --git a/apps/meeseeks_api/README.md b/apps/meeseeks_api/README.md index 6c6e3ffa..2c05806b 100644 --- a/apps/meeseeks_api/README.md +++ b/apps/meeseeks_api/README.md @@ -4,9 +4,9 @@ GitHub Release

-- REST API Engine wrapped around the meeseeks-core. -- No components are explicitly tested for safety or security. Use with caution in a production environment. -- For full setup and configuration, see `docs/getting-started.md`. +- REST API engine wrapped around meeseeks-core. +- No components are explicitly tested for safety or security. Use with caution in production. +- For setup and configuration, see `docs/getting-started.md`. ## Run ```bash @@ -21,6 +21,14 @@ uv run meeseeks-api - `GET /api/sessions/{session_id}/events?after=...` poll events - `POST /api/sessions/{session_id}/archive` archive a session - `DELETE /api/sessions/{session_id}/archive` unarchive a session -- `POST /api/query` legacy synchronous endpoint +- `POST /api/query` synchronous endpoint (simple/CLI-compatible) +- `GET /api/tools` list tool registry entries +- `GET /api/notifications` list notifications +- `POST /api/notifications/dismiss` dismiss notifications +- `POST /api/notifications/clear` clear notifications +- `POST /api/sessions/{session_id}/attachments` upload attachments +- `POST /api/sessions/{session_id}/share` create share link +- `POST /api/sessions/{session_id}/export` export session payload +- `GET /api/share/{token}` fetch shared session data [Link to GitHub Repository](https://github.com/bearlike/Assistant) diff --git a/apps/meeseeks_api/pyproject.toml b/apps/meeseeks_api/pyproject.toml index 5764aa27..8b8eae6e 100644 --- a/apps/meeseeks_api/pyproject.toml +++ b/apps/meeseeks_api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-api" -version = "2.1.0-alpha" +version = "0.0.7" description = "REST API Engine wrapped around the Meeseeks core." readme = "../../README.md" requires-python = ">=3.10,<4.0" @@ -10,8 +10,8 @@ authors = [ license = { text = "MIT" } dependencies = [ - "meeseeks-core>=2.1.0-alpha", - "meeseeks-tools>=2.1.0-alpha", + "meeseeks-core>=0.0.7", + "meeseeks-tools>=0.0.7", "flask>=3.0.3,<4.0.0", "flask-restx>=1.3.0,<2.0.0", ] diff --git a/apps/meeseeks_api/src/meeseeks_api/backend.py b/apps/meeseeks_api/src/meeseeks_api/backend.py index 9f5b3f66..4c2bb44f 100644 --- a/apps/meeseeks_api/src/meeseeks_api/backend.py +++ b/apps/meeseeks_api/src/meeseeks_api/backend.py @@ -6,17 +6,122 @@ from __future__ import annotations +import os +import uuid from copy import deepcopy +from datetime import datetime, timezone from flask import Flask, request from flask_restx import Api, Resource, fields from meeseeks_core.classes import TaskQueue from meeseeks_core.common import get_logger from meeseeks_core.config import get_config, get_config_value, start_preflight +from meeseeks_core.notifications import NotificationStore from meeseeks_core.permissions import auto_approve from meeseeks_core.session_runtime import SessionRuntime, parse_core_command from meeseeks_core.session_store import SessionStore +from meeseeks_core.share_store import ShareStore from meeseeks_core.tool_registry import load_registry +from werkzeug.utils import secure_filename + + +class NotificationService: + """Emit session lifecycle notifications for the API.""" + + def __init__(self, store: NotificationStore, session_store: SessionStore) -> None: + """Initialize with notification and session stores.""" + self._store = store + self._session_store = session_store + + def notify( + self, + *, + title: str, + message: str, + level: str = "info", + session_id: str | None = None, + event_type: str | None = None, + metadata: dict[str, object] | None = None, + ) -> None: + """Persist a notification record.""" + self._store.add( + title=title, + message=message, + level=level, + session_id=session_id, + event_type=event_type, + metadata=metadata, + ) + + def emit_session_created(self, session_id: str) -> None: + """Append a session-created event and notify.""" + self._session_store.append_event( + session_id, + {"type": "session", "payload": {"event": "created"}}, + ) + self.notify( + title="Session created", + message=f"Session {session_id} created.", + session_id=session_id, + event_type="created", + ) + + def emit_started(self, session_id: str) -> None: + """Notify that a session started running.""" + self.notify( + title="Session started", + message=f"Session {session_id} started.", + session_id=session_id, + event_type="started", + ) + + def emit_completion(self, session_id: str) -> None: + """Emit a completion notification based on the latest completion event.""" + events = self._session_store.load_recent_events( + session_id, + limit=1, + include_types={"completion"}, + ) + if not events: + return + event = events[-1] + completion_ts = event.get("ts") + if not completion_ts or self._completion_exists(session_id, completion_ts): + return + payload = event.get("payload") + if not isinstance(payload, dict): + return + done = bool(payload.get("done")) + done_reason = str(payload.get("done_reason") or "") + if done and done_reason.lower() == "completed": + self.notify( + title="Session completed", + message=f"Session {session_id} completed.", + session_id=session_id, + event_type="completed", + metadata={"completion_ts": completion_ts, "done_reason": done_reason}, + ) + return + self.notify( + title="Session finished", + message=f"Session {session_id} finished with status '{done_reason}'.", + level="warning", + session_id=session_id, + event_type="failed", + metadata={"completion_ts": completion_ts, "done_reason": done_reason}, + ) + + def _completion_exists(self, session_id: str, completion_ts: str) -> bool: + for item in self._store.list(include_dismissed=True): + if item.get("session_id") != session_id: + continue + if item.get("event_type") not in {"completed", "failed"}: + continue + metadata = item.get("metadata") or {} + if metadata.get("completion_ts") == completion_ts: + return True + return False + # Get the API token from app config MASTER_API_TOKEN = get_config_value("api", "master_token", default="msk-strong-password") @@ -35,6 +140,8 @@ app = Flask(__name__) session_store = SessionStore() runtime = SessionRuntime(session_store=session_store) +notification_store = NotificationStore(root_dir=session_store.root_dir) +share_store = ShareStore(root_dir=session_store.root_dir) authorizations = {"apikey": {"type": "apiKey", "in": "header", "name": "X-API-KEY"}} VERSION = get_config_value("runtime", "version", default="(Dev)") @@ -53,6 +160,23 @@ task_queue_model = api.model( "TaskQueue", { + "plan_steps": fields.List( + fields.Nested( + api.model( + "PlanStep", + { + "title": fields.String( + required=True, + description="Short title for the plan step", + ), + "description": fields.String( + required=True, + description="Brief description of the step", + ), + }, + ) + ) + ), "session_id": fields.String( required=False, description="Session identifier for transcript storage" ), @@ -65,16 +189,16 @@ api.model( "ActionStep", { - "action_consumer": fields.String( + "tool_id": fields.String( required=True, description="The tool responsible for executing the action", ), - "action_type": fields.String( + "operation": fields.String( required=True, description="The type of action to be performed (get/set)", ), - "action_argument": fields.String( - required=True, description="The specific argument for the action" + "tool_input": fields.Raw( + required=True, description="Arguments for the tool invocation" ), "result": fields.String(description="The result of the executed action"), }, @@ -94,6 +218,7 @@ def log_request_info() -> None: def _require_api_key() -> tuple[dict, int] | None: + """Validate the API key header for protected routes.""" api_token = request.headers.get("X-API-Key", None) if api_token is None: return {"message": "API token is not provided."}, 401 @@ -104,6 +229,7 @@ def _require_api_key() -> tuple[dict, int] | None: def _handle_slash_command(session_id: str, user_query: str) -> tuple[dict, int] | None: + """Handle session slash commands like /terminate and /status.""" command = parse_core_command(user_query) if command == "/terminate": canceled = runtime.cancel(session_id) @@ -114,6 +240,7 @@ def _handle_slash_command(session_id: str, user_query: str) -> tuple[dict, int] def _parse_bool(value: str | None) -> bool: + """Interpret a query param or payload value as a boolean.""" if value is None: return False lowered = value.strip().lower() @@ -122,6 +249,36 @@ def _parse_bool(value: str | None) -> bool: return lowered not in {"0", "false", "no", "off"} +def _parse_mode(value: object | None) -> str | None: + """Normalize orchestration mode values to 'plan' or 'act'.""" + if not isinstance(value, str): + return None + lowered = value.strip().lower() + if lowered in {"plan", "act"}: + return lowered + return None + + +def _utc_now() -> str: + """Return current UTC timestamp string.""" + return datetime.now(timezone.utc).isoformat() + + +def _build_context_payload(request_data: dict[str, object]) -> dict[str, object]: + """Merge context and attachments into a single payload.""" + payload: dict[str, object] = {} + context = request_data.get("context") + if isinstance(context, dict): + payload.update(context) + attachments = request_data.get("attachments") + if isinstance(attachments, list): + payload["attachments"] = attachments + return payload + + +notification_service = NotificationService(notification_store, runtime.session_store) + + @ns.route("/sessions") class Sessions(Resource): """List and create sessions.""" @@ -144,12 +301,13 @@ def post(self) -> tuple[dict, int]: return auth_error payload = request.get_json(silent=True) or {} session_id = runtime.session_store.create_session() + notification_service.emit_session_created(session_id) session_tag = payload.get("session_tag") if session_tag: runtime.session_store.tag_session(session_id, session_tag) - context = payload.get("context") - if isinstance(context, dict): - runtime.append_context_event(session_id, context) + context_payload = _build_context_payload(payload) + if context_payload: + runtime.append_context_event(session_id, context_payload) return {"session_id": session_id}, 200 @@ -175,17 +333,21 @@ def post(self, session_id: str) -> tuple[dict, int]: if runtime.is_running(session_id): return {"message": "Session is already running."}, 409 - context = request_data.get("context") - if isinstance(context, dict): - runtime.append_context_event(session_id, context) + context_payload = _build_context_payload(request_data) + if context_payload: + runtime.append_context_event(session_id, context_payload) + + mode = _parse_mode(request_data.get("mode")) started = runtime.start_async( session_id=session_id, user_query=user_query, approval_callback=auto_approve, + mode=mode, ) if not started: return {"message": "Session is already running."}, 409 + notification_service.emit_started(session_id) return {"session_id": session_id, "accepted": True}, 202 @@ -201,6 +363,7 @@ def get(self, session_id: str) -> tuple[dict, int]: return auth_error after_ts = request.args.get("after") events = runtime.load_events(session_id, after_ts) + notification_service.emit_completion(session_id) return { "session_id": session_id, "events": events, @@ -235,6 +398,165 @@ def delete(self, session_id: str) -> tuple[dict, int]: return {"session_id": session_id, "archived": False}, 200 +@ns.route("/sessions//attachments") +class SessionAttachments(Resource): + """Upload attachments for a session.""" + + @api.doc(security="apikey") + def post(self, session_id: str) -> tuple[dict, int]: + """Upload one or more files for a session.""" + auth_error = _require_api_key() + if auth_error: + return auth_error + if session_id not in runtime.session_store.list_sessions(): + return {"message": "Session not found."}, 404 + files = request.files.getlist("files") + if not files and "file" in request.files: + files = [request.files["file"]] + if not files: + return {"message": "No files uploaded."}, 400 + attachments_dir = os.path.join( + runtime.session_store.root_dir, + session_id, + "attachments", + ) + os.makedirs(attachments_dir, exist_ok=True) + saved: list[dict[str, object]] = [] + for item in files: + if not item or not item.filename: + continue + attachment_id = uuid.uuid4().hex + safe_name = secure_filename(item.filename) + stored_name = f"{attachment_id}_{safe_name}" if safe_name else attachment_id + path = os.path.join(attachments_dir, stored_name) + item.save(path) + size_bytes = os.path.getsize(path) + saved.append( + { + "id": attachment_id, + "filename": item.filename, + "stored_name": stored_name, + "content_type": item.mimetype, + "size_bytes": size_bytes, + "uploaded_at": _utc_now(), + } + ) + if not saved: + return {"message": "No valid files uploaded."}, 400 + return {"attachments": saved}, 200 + + +@ns.route("/sessions//share") +class SessionShare(Resource): + """Create a share token for a session.""" + + @api.doc(security="apikey") + def post(self, session_id: str) -> tuple[dict, int]: + """Create a share token for the session.""" + auth_error = _require_api_key() + if auth_error: + return auth_error + if session_id not in runtime.session_store.list_sessions(): + return {"message": "Session not found."}, 404 + record = share_store.create(session_id) + return record, 200 + + +@ns.route("/sessions//export") +class SessionExport(Resource): + """Export transcript data for a session.""" + + @api.doc(security="apikey") + def get(self, session_id: str) -> tuple[dict, int]: + """Return transcript and summary for a session.""" + auth_error = _require_api_key() + if auth_error: + return auth_error + if session_id not in runtime.session_store.list_sessions(): + return {"message": "Session not found."}, 404 + return { + "session_id": session_id, + "events": runtime.session_store.load_transcript(session_id), + "summary": runtime.session_store.load_summary(session_id), + }, 200 + + +@ns.route("/share/") +class ShareLookup(Resource): + """Resolve a share token to a session export.""" + + def get(self, token: str) -> tuple[dict, int]: + """Return transcript and summary for a share token.""" + record = share_store.resolve(token) + if not record: + return {"message": "Share token not found."}, 404 + session_id = record["session_id"] + return { + "token": token, + "session_id": session_id, + "created_at": record.get("created_at"), + "events": runtime.session_store.load_transcript(session_id), + "summary": runtime.session_store.load_summary(session_id), + }, 200 + + +@ns.route("/notifications") +class Notifications(Resource): + """List notifications.""" + + @api.doc(security="apikey") + def get(self) -> tuple[dict, int]: + """Return notifications for the UI.""" + auth_error = _require_api_key() + if auth_error: + return auth_error + include_dismissed = _parse_bool(request.args.get("include_dismissed")) + return { + "notifications": notification_store.list(include_dismissed=include_dismissed), + }, 200 + + +@ns.route("/notifications/dismiss") +class NotificationDismiss(Resource): + """Dismiss notifications.""" + + @api.doc(security="apikey") + def post(self) -> tuple[dict, int]: + """Dismiss a notification or list of notifications.""" + auth_error = _require_api_key() + if auth_error: + return auth_error + payload = request.get_json(silent=True) or {} + ids: list[str] = [] + ids_payload = payload.get("ids") + if isinstance(ids_payload, list): + ids = [str(item) for item in ids_payload if item] + elif payload.get("id"): + ids = [str(payload.get("id"))] + dismissed = notification_store.dismiss(ids) + return {"dismissed": dismissed}, 200 + + +@ns.route("/notifications/clear") +class NotificationClear(Resource): + """Clear notifications.""" + + @api.doc(security="apikey") + def post(self) -> tuple[dict, int]: + """Clear dismissed notifications (or all when clear_all is true).""" + auth_error = _require_api_key() + if auth_error: + return auth_error + payload = request.get_json(silent=True) or {} + clear_all = payload.get("clear_all") + if isinstance(clear_all, str): + clear_all = _parse_bool(clear_all) + else: + clear_all = bool(clear_all) + cleared = notification_store.clear(dismissed_only=not clear_all) + return {"cleared": cleared}, 200 + + @ns.route("/tools") class Tools(Resource): """List available tool integrations.""" @@ -275,6 +597,10 @@ class MeeseeksQuery(Resource): "session_id": fields.String(required=False, description="Existing session id"), "session_tag": fields.String(required=False, description="Human-friendly tag"), "fork_from": fields.String(required=False, description="Session id or tag to fork"), + "mode": fields.String( + required=False, + description="Optional orchestration mode (plan or act)", + ), }, ) ) @@ -290,18 +616,28 @@ def post(self) -> tuple[dict, int]: user_query = request_data.get("query") if not user_query: return {"message": "Invalid input: 'query' is required"}, 400 + mode = _parse_mode(request_data.get("mode")) + existing_sessions = set(runtime.session_store.list_sessions()) session_id = runtime.resolve_session( session_id=request_data.get("session_id"), session_tag=request_data.get("session_tag"), fork_from=request_data.get("fork_from"), ) + if session_id not in existing_sessions: + notification_service.emit_session_created(session_id) + context_payload = _build_context_payload(request_data) + if context_payload: + runtime.append_context_event(session_id, context_payload) + notification_service.emit_started(session_id) logging.info("Received user query: {}", user_query) task_queue: TaskQueue = runtime.run_sync( user_query=user_query, session_id=session_id, approval_callback=auto_approve, + mode=mode, ) + notification_service.emit_completion(session_id) task_result = deepcopy(task_queue.task_result) to_return = task_queue.dict() to_return["task_result"] = task_result diff --git a/apps/meeseeks_api/tests/test_backend.py b/apps/meeseeks_api/tests/test_backend.py index 013dd6be..6ac7c65e 100644 --- a/apps/meeseeks_api/tests/test_backend.py +++ b/apps/meeseeks_api/tests/test_backend.py @@ -1,6 +1,7 @@ """Tests for the Meeseeks API backend.""" # mypy: ignore-errors +import io import json import time @@ -13,11 +14,17 @@ class DummyQueue: def __init__(self, result: str) -> None: """Initialize the dummy queue with a single action result.""" self.task_result = result + self.plan_steps = [ + { + "title": "Say hello", + "description": "Respond to the user.", + } + ] self.action_steps = [ { - "action_consumer": "home_assistant_tool", - "action_type": "get", - "action_argument": "say", + "tool_id": "home_assistant_tool", + "operation": "get", + "tool_input": "say", "result": result, } ] @@ -26,6 +33,7 @@ def dict(self): """Return a serialized representation of the queue.""" return { "task_result": self.task_result, + "plan_steps": list(self.plan_steps), "action_steps": list(self.action_steps), } @@ -56,8 +64,10 @@ def test_query_invalid_input(monkeypatch): def test_query_success(monkeypatch): """Return a task result payload when authorized.""" client = backend.app.test_client() + captured = {} def fake_run_sync(*args, **kwargs): + captured["mode"] = kwargs.get("mode") return _make_task_queue("ok") monkeypatch.setattr(backend.runtime, "run_sync", fake_run_sync) @@ -70,13 +80,70 @@ def fake_run_sync(*args, **kwargs): payload = response.get_json() assert payload["task_result"] == "ok" assert payload["session_id"] + assert payload["plan_steps"] assert payload["action_steps"] + assert captured["mode"] is None + + +def test_query_with_mode(monkeypatch): + """Pass through orchestration mode when provided.""" + client = backend.app.test_client() + captured = {} + + def fake_run_sync(*args, **kwargs): + captured["mode"] = kwargs.get("mode") + return _make_task_queue("ok") + + monkeypatch.setattr(backend.runtime, "run_sync", fake_run_sync) + response = client.post( + "/api/query", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"query": "hello", "mode": "plan"}, + ) + assert response.status_code == 200 + assert captured["mode"] == "plan" + + +def test_api_auto_approves_permissions(monkeypatch, tmp_path): + """API requests should always use auto-approve callback.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + session_id = backend.session_store.create_session() + + captured = {} + + def fake_start_async(*_args, **kwargs): + captured["approval_callback"] = kwargs.get("approval_callback") + return True + + monkeypatch.setattr(backend.runtime, "start_async", fake_start_async) + response = client.post( + f"/api/sessions/{session_id}/query", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"query": "hello"}, + ) + assert response.status_code == 202 + assert captured["approval_callback"] is backend.auto_approve + + captured.clear() + + def fake_run_sync(*_args, **kwargs): + captured["approval_callback"] = kwargs.get("approval_callback") + return _make_task_queue("ok") + + monkeypatch.setattr(backend.runtime, "run_sync", fake_run_sync) + response = client.post( + "/api/query", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"query": "hello"}, + ) + assert response.status_code == 200 + assert captured["approval_callback"] is backend.auto_approve def test_query_with_session_tag(monkeypatch, tmp_path): """Create or reuse a tagged session and pass it into orchestration.""" - backend.session_store = backend.SessionStore(root_dir=str(tmp_path)) - backend.runtime = backend.SessionRuntime(session_store=backend.session_store) + _reset_backend(tmp_path, monkeypatch) client = backend.app.test_client() captured = {} @@ -98,8 +165,7 @@ def fake_run_sync(*args, **kwargs): def test_query_fork_from(monkeypatch, tmp_path): """Fork a session when requested and pass the fork into orchestration.""" - backend.session_store = backend.SessionStore(root_dir=str(tmp_path)) - backend.runtime = backend.SessionRuntime(session_store=backend.session_store) + _reset_backend(tmp_path, monkeypatch) source_session = backend.session_store.create_session() client = backend.app.test_client() captured = {} @@ -123,6 +189,12 @@ def fake_run_sync(*args, **kwargs): def _reset_backend(tmp_path, monkeypatch): backend.session_store = backend.SessionStore(root_dir=str(tmp_path)) backend.runtime = backend.SessionRuntime(session_store=backend.session_store) + backend.notification_store = backend.NotificationStore(root_dir=str(tmp_path)) + backend.share_store = backend.ShareStore(root_dir=str(tmp_path)) + backend.notification_service = backend.NotificationService( + backend.notification_store, + backend.runtime.session_store, + ) def _fake_run_sync(*, session_id: str, user_query: str, should_cancel=None, **_kwargs): @@ -213,6 +285,25 @@ def test_sessions_list_skips_empty(monkeypatch, tmp_path): assert all(item["session_id"] != empty_session for item in sessions) +def test_sessions_create_adds_event(monkeypatch, tmp_path): + """Include newly created sessions in listings even without context.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + create = client.post( + "/api/sessions", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={}, + ) + assert create.status_code == 200 + session_id = create.get_json()["session_id"] + listing = client.get( + "/api/sessions", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + sessions = listing.get_json()["sessions"] + assert any(item["session_id"] == session_id for item in sessions) + + def test_sessions_archive_and_list(monkeypatch, tmp_path): """Archive sessions and include them when requested.""" _reset_backend(tmp_path, monkeypatch) @@ -251,6 +342,258 @@ def test_sessions_archive_and_list(monkeypatch, tmp_path): assert unarchive.get_json()["archived"] is False +def test_notifications_endpoints(monkeypatch, tmp_path): + """Create, dismiss, and clear notifications.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + create = client.post( + "/api/sessions", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={}, + ) + assert create.status_code == 200 + listing = client.get( + "/api/notifications", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + assert listing.status_code == 200 + notifications = listing.get_json()["notifications"] + assert notifications + first_id = notifications[0]["id"] + + dismiss = client.post( + "/api/notifications/dismiss", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"id": first_id}, + ) + assert dismiss.status_code == 200 + assert dismiss.get_json()["dismissed"] == 1 + + listing = client.get( + "/api/notifications?include_dismissed=1", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + notifications = listing.get_json()["notifications"] + dismissed = next(item for item in notifications if item["id"] == first_id) + assert dismissed.get("dismissed") is True + + cleared = client.post( + "/api/notifications/clear", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={}, + ) + assert cleared.status_code == 200 + listing = client.get( + "/api/notifications?include_dismissed=1", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + notifications = listing.get_json()["notifications"] + assert all(item["id"] != first_id for item in notifications) + + +def test_notifications_require_api_key(monkeypatch, tmp_path): + """Require authentication headers for notification endpoints.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + assert client.get("/api/notifications").status_code == 401 + assert client.post("/api/notifications/dismiss").status_code == 401 + assert client.post("/api/notifications/clear").status_code == 401 + + +def test_notifications_dismiss_ids_and_clear_all(monkeypatch, tmp_path): + """Dismiss multiple ids and clear all notifications.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + first = backend.notification_store.add(title="one", message="first") + second = backend.notification_store.add(title="two", message="second") + + dismiss = client.post( + "/api/notifications/dismiss", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"ids": [first["id"], second["id"]]}, + ) + assert dismiss.status_code == 200 + assert dismiss.get_json()["dismissed"] == 2 + + cleared = client.post( + "/api/notifications/clear", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={"clear_all": "true"}, + ) + assert cleared.status_code == 200 + assert cleared.get_json()["cleared"] == 2 + + +def test_notification_service_skips_invalid_completion_payload(monkeypatch, tmp_path): + """Skip completion notifications with invalid payloads.""" + _reset_backend(tmp_path, monkeypatch) + session_id = backend.session_store.create_session() + + monkeypatch.setattr( + backend.session_store, + "load_recent_events", + lambda *_args, **_kwargs: [{"type": "completion", "payload": "bad", "ts": "1"}], + ) + backend.notification_service.emit_completion(session_id) + assert backend.notification_store.list(include_dismissed=True) == [] + + +def test_notification_service_skips_missing_timestamp(monkeypatch, tmp_path): + """Skip completion notifications without timestamps.""" + _reset_backend(tmp_path, monkeypatch) + session_id = backend.session_store.create_session() + + monkeypatch.setattr( + backend.session_store, + "load_recent_events", + lambda *_args, **_kwargs: [{"type": "completion", "payload": {"done": True}}], + ) + backend.notification_service.emit_completion(session_id) + assert backend.notification_store.list(include_dismissed=True) == [] + + +def test_notification_service_avoids_duplicate_completion(monkeypatch, tmp_path): + """Avoid duplicate completion notifications for the same timestamp.""" + _reset_backend(tmp_path, monkeypatch) + session_id = backend.session_store.create_session() + other_session = backend.session_store.create_session() + backend.notification_store.add( + title="Other session", + message="Other complete", + session_id=other_session, + event_type="completed", + metadata={"completion_ts": "other"}, + ) + backend.session_store.append_event( + session_id, + { + "type": "completion", + "payload": {"done": True, "done_reason": "completed", "task_result": "ok"}, + }, + ) + backend.notification_service.emit_completion(session_id) + backend.notification_service.emit_completion(session_id) + notifications = backend.notification_store.list(include_dismissed=True) + session_notifications = [item for item in notifications if item.get("session_id") == session_id] + assert len(session_notifications) == 1 + + +def test_attachments_upload(monkeypatch, tmp_path): + """Upload attachments and return metadata.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + create = client.post( + "/api/sessions", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={}, + ) + session_id = create.get_json()["session_id"] + data = {"file": (io.BytesIO(b"hello"), "note.txt")} + response = client.post( + f"/api/sessions/{session_id}/attachments", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + data=data, + content_type="multipart/form-data", + ) + assert response.status_code == 200 + attachments = response.get_json()["attachments"] + assert attachments + stored_name = attachments[0]["stored_name"] + path = tmp_path / session_id / "attachments" / stored_name + assert path.exists() + + +def test_attachments_errors(monkeypatch, tmp_path): + """Return validation errors for attachment uploads.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + session_id = backend.session_store.create_session() + + unauthorized = client.post(f"/api/sessions/{session_id}/attachments") + assert unauthorized.status_code == 401 + + missing = client.post( + "/api/sessions/missing/attachments", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + data={}, + content_type="multipart/form-data", + ) + assert missing.status_code == 404 + + no_files = client.post( + f"/api/sessions/{session_id}/attachments", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + data={}, + content_type="multipart/form-data", + ) + assert no_files.status_code == 400 + + invalid_name = client.post( + f"/api/sessions/{session_id}/attachments", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + data={"file": (io.BytesIO(b"data"), "")}, + content_type="multipart/form-data", + ) + assert invalid_name.status_code == 400 + + +def test_share_and_export(monkeypatch, tmp_path): + """Create share tokens and export session transcripts.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + create = client.post( + "/api/sessions", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={}, + ) + session_id = create.get_json()["session_id"] + share = client.post( + f"/api/sessions/{session_id}/share", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + assert share.status_code == 200 + token = share.get_json()["token"] + + export = client.get( + f"/api/sessions/{session_id}/export", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + assert export.status_code == 200 + assert export.get_json()["session_id"] == session_id + + shared = client.get(f"/api/share/{token}") + assert shared.status_code == 200 + payload = shared.get_json() + assert payload["session_id"] == session_id + + +def test_share_and_export_errors(monkeypatch, tmp_path): + """Return error responses for missing share/export inputs.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + + unauthorized_share = client.post("/api/sessions/missing/share") + assert unauthorized_share.status_code == 401 + + missing_share = client.post( + "/api/sessions/missing/share", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + assert missing_share.status_code == 404 + + unauthorized_export = client.get("/api/sessions/missing/export") + assert unauthorized_export.status_code == 401 + + missing_export = client.get( + "/api/sessions/missing/export", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + ) + assert missing_export.status_code == 404 + + missing_token = client.get("/api/share/does-not-exist") + assert missing_token.status_code == 404 + + def test_slash_command_terminate(monkeypatch, tmp_path): """Terminate a running session via slash command.""" _reset_backend(tmp_path, monkeypatch) @@ -297,6 +640,54 @@ def slow_run_sync(*, session_id: str, user_query: str, should_cancel=None, **_kw assert payload["events"][-1]["payload"]["done_reason"] == "canceled" +def test_query_appends_context_payload(monkeypatch, tmp_path): + """Append context/attachments payload to query events.""" + _reset_backend(tmp_path, monkeypatch) + client = backend.app.test_client() + captured = [] + + def fake_append_context_event(session_id, payload): + captured.append((session_id, payload)) + + monkeypatch.setattr(backend.runtime, "append_context_event", fake_append_context_event) + monkeypatch.setattr(backend.runtime, "start_async", lambda **_kwargs: True) + + session_id = backend.session_store.create_session() + response = client.post( + f"/api/sessions/{session_id}/query", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={ + "query": "hello", + "context": {"repo": "acme/app"}, + "attachments": [{"id": "file-1", "filename": "note.txt"}], + }, + ) + assert response.status_code == 202 + assert captured[0][1]["attachments"] + + captured.clear() + + def fake_run_sync(*_args, **_kwargs): + return _make_task_queue("ok") + + monkeypatch.setattr(backend.runtime, "run_sync", fake_run_sync) + response = client.post( + "/api/query", + headers={"X-API-KEY": backend.MASTER_API_TOKEN}, + json={ + "query": "hello", + "attachments": [{"id": "file-1", "filename": "note.txt"}], + }, + ) + assert response.status_code == 200 + assert captured + + +def test_parse_mode_invalid_value(): + """Return None for invalid mode inputs.""" + assert backend._parse_mode("invalid") is None + + def test_tools_list(monkeypatch, tmp_path): """Return tool metadata for the MCP picker.""" _reset_backend(tmp_path, monkeypatch) diff --git a/apps/meeseeks_chat/AGENTS.md b/apps/meeseeks_chat/AGENTS.md index 76ce2d61..6a7f95be 100644 --- a/apps/meeseeks_chat/AGENTS.md +++ b/apps/meeseeks_chat/AGENTS.md @@ -7,7 +7,7 @@ Scope: this file applies to the `apps/meeseeks_chat/` UI app. It captures runtim - Uses `generate_action_plan(...)` for preview and `orchestrate_session(...)` for execution. - Stores session state in `st.session_state`: - `session_store`, `session_id`, `messages`, and `conversation_memory`. -- Action plan is displayed in an expander ("thought" role). +- Action plan (title/description steps) is displayed in an expander ("thought" role). ## Hidden dependencies / assumptions - Expects static assets packaged under `src/meeseeks_chat/static/`. diff --git a/apps/meeseeks_chat/README.md b/apps/meeseeks_chat/README.md index a34662be..71e2405d 100644 --- a/apps/meeseeks_chat/README.md +++ b/apps/meeseeks_chat/README.md @@ -10,7 +10,7 @@

-- Chat Interface wrapped around the meeseeks-core. Powered by Streamlit. +- Chat interface wrapped around meeseeks-core. Powered by Streamlit. - For full setup and configuration, see `docs/getting-started.md`. ## Run diff --git a/apps/meeseeks_chat/pyproject.toml b/apps/meeseeks_chat/pyproject.toml index 4f71b879..38020045 100644 --- a/apps/meeseeks_chat/pyproject.toml +++ b/apps/meeseeks_chat/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-chat" -version = "2.1.0-alpha" +version = "0.0.7" description = "Chat Interface wrapped around the Meeseeks core. Powered by Streamlit." readme = "../../README.md" requires-python = ">=3.10,<4.0" @@ -10,8 +10,8 @@ authors = [ license = { text = "MIT" } dependencies = [ - "meeseeks-core>=2.1.0-alpha", - "meeseeks-tools>=2.1.0-alpha", + "meeseeks-core>=0.0.7", + "meeseeks-tools>=0.0.7", "streamlit>=1.34.0,<2.0.0", ] diff --git a/apps/meeseeks_chat/src/meeseeks_chat/chat_master.py b/apps/meeseeks_chat/src/meeseeks_chat/chat_master.py index b7e5a01c..7bf8f81b 100644 --- a/apps/meeseeks_chat/src/meeseeks_chat/chat_master.py +++ b/apps/meeseeks_chat/src/meeseeks_chat/chat_master.py @@ -15,7 +15,7 @@ # Third-party modules import streamlit as st -from meeseeks_core.classes import TaskQueue +from meeseeks_core.classes import Plan from meeseeks_core.common import get_logger from meeseeks_core.permissions import auto_approve from meeseeks_core.session_store import SessionStore @@ -40,40 +40,36 @@ def save_context(self, inputs: dict[str, object], outputs: dict[str, object]) -> logging = get_logger(name="Meeseeks-Chat") -def generate_action_plan_helper(user_input: str) -> tuple[list[str], TaskQueue]: +def generate_action_plan_helper(user_input: str) -> tuple[list[str], Plan]: """Build the action plan preview for a user query. Args: user_input: Raw user query text. Returns: - Tuple of human-readable action plan entries and the task queue. + Tuple of human-readable action plan entries and the plan. """ action_plan_list = [] - task_queue = generate_action_plan(user_query=user_input) - for action_step in task_queue.action_steps: - # * Append action step to the action plan list - action_plan_list.append( - f"Using `{action_step.action_consumer}` with " - f"`{action_step.action_type}` to `{action_step.action_argument}`" - ) - return action_plan_list, task_queue + plan = generate_action_plan(user_query=user_input) + for step in plan.steps: + action_plan_list.append(f"{step.title}: {step.description}") + return action_plan_list, plan -def run_action_plan_helper(task_queue: TaskQueue) -> str: +def run_action_plan_helper(plan: Plan) -> str: """Execute an action plan and combine tool responses. Args: - task_queue: Precomputed task queue to run. + plan: Precomputed plan to run. Returns: Combined tool responses as a single string. """ responses: list[str] = [] task_queue = orchestrate_session( - user_query=task_queue.human_message or "", + user_query=plan.human_message or "", model_name=None, - initial_task_queue=task_queue, + initial_plan=plan, session_id=st.session_state.session_id, session_store=st.session_state.session_store, approval_callback=auto_approve, diff --git a/apps/meeseeks_chat/tests/test_chat_master.py b/apps/meeseeks_chat/tests/test_chat_master.py index feae4395..8d650cd4 100644 --- a/apps/meeseeks_chat/tests/test_chat_master.py +++ b/apps/meeseeks_chat/tests/test_chat_master.py @@ -6,7 +6,7 @@ import types from meeseeks_chat import chat_master -from meeseeks_core.classes import ActionStep, TaskQueue +from meeseeks_core.classes import ActionStep, Plan, PlanStep, TaskQueue from meeseeks_core.session_store import SessionStore @@ -18,24 +18,15 @@ def _make_task_queue(action_steps): def test_generate_action_plan_helper(monkeypatch): """Return a formatted action plan with the generated queue.""" - steps = [ - ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hello", - ) - ] - task_queue = _make_task_queue(steps) + plan = Plan(steps=[PlanStep(title="Say hello", description="Respond to the user.")]) def fake_generate(*args, **kwargs): - return task_queue + return plan monkeypatch.setattr(chat_master, "generate_action_plan", fake_generate) - plan, returned = chat_master.generate_action_plan_helper("hello") - assert returned == task_queue - assert plan == [ - "Using `home_assistant_tool` with `get` to `hello`", - ] + plan_list, returned = chat_master.generate_action_plan_helper("hello") + assert returned == plan + assert plan_list == ["Say hello: Respond to the user."] def test_run_action_plan_helper(monkeypatch, tmp_path): @@ -48,27 +39,28 @@ def test_run_action_plan_helper(monkeypatch, tmp_path): ) monkeypatch.setattr(chat_master, "st", types.SimpleNamespace(session_state=fake_state)) - steps = [ - ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hello", - ) - ] - task_queue = _make_task_queue(steps) + plan = Plan(steps=[PlanStep(title="Say hello", description="Respond to the user.")]) captured = {} def fake_orchestrate(*args, **kwargs): captured["session_id"] = kwargs.get("session_id") captured["session_store"] = kwargs.get("session_store") - MockSpeaker = type("Result", (), {"content": "ok"}) - task_queue.action_steps[0].result = MockSpeaker() - task_queue.task_result = "ok" - return task_queue + queue = _make_task_queue( + [ + ActionStep( + tool_id="home_assistant_tool", + operation="get", + tool_input="hello", + result=type("Result", (), {"content": "ok"})(), + ) + ] + ) + queue.task_result = "ok" + return queue monkeypatch.setattr(chat_master, "orchestrate_session", fake_orchestrate) - response = chat_master.run_action_plan_helper(task_queue) + response = chat_master.run_action_plan_helper(plan) assert response == "ok" assert captured["session_id"] == session_id assert captured["session_store"] is session_store @@ -134,14 +126,8 @@ def save_context(self, inputs, outputs): monkeypatch.chdir(chat_root) def fake_generate(user_input): - steps = [ - ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hello", - ) - ] - return ["Using `home_assistant_tool` with `get` to `hello`"], TaskQueue(action_steps=steps) + plan = Plan(steps=[PlanStep(title="Say hello", description="Respond to the user.")]) + return ["Say hello: Respond to the user."], plan monkeypatch.setattr(chat_master, "generate_action_plan_helper", fake_generate) monkeypatch.setattr(chat_master, "run_action_plan_helper", lambda *_: "ok") diff --git a/apps/meeseeks_cli/pyproject.toml b/apps/meeseeks_cli/pyproject.toml index 89cc5c86..bf73b97e 100644 --- a/apps/meeseeks_cli/pyproject.toml +++ b/apps/meeseeks_cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-cli" -version = "2.1.0-alpha" +version = "0.0.7" description = "Terminal CLI frontend for the Meeseeks core orchestration engine." readme = "../../README.md" requires-python = ">=3.10,<4.0" @@ -10,8 +10,8 @@ authors = [ license = { text = "MIT" } dependencies = [ - "meeseeks-core>=2.1.0-alpha", - "meeseeks-tools>=2.1.0-alpha", + "meeseeks-core>=0.0.7", + "meeseeks-tools>=0.0.7", "prompt-toolkit>=3.0.47,<4.0.0", "rich>=14.2.0,<15.0.0", "textual>=7.5.0,<8.0.0", diff --git a/apps/meeseeks_cli/src/meeseeks_cli/cli_dialogs.py b/apps/meeseeks_cli/src/meeseeks_cli/cli_dialogs.py index a0ca78dd..d4bdd8d7 100644 --- a/apps/meeseeks_cli/src/meeseeks_cli/cli_dialogs.py +++ b/apps/meeseeks_cli/src/meeseeks_cli/cli_dialogs.py @@ -17,7 +17,7 @@ from textual.widgets import Input, Label, OptionList, SelectionList _DOTTED_BOX = box.Box( - ".:.:" "\n: ::" "\n.:.:" "\n: ::" "\n.:.:" "\n.:.:" "\n: ::" "\n.:.:", + ".:.:\n: ::\n.:.:\n: ::\n.:.:\n.:.:\n: ::\n.:.:", ascii=True, ) diff --git a/apps/meeseeks_cli/src/meeseeks_cli/cli_master.py b/apps/meeseeks_cli/src/meeseeks_cli/cli_master.py index 10c4b393..e1d76eac 100644 --- a/apps/meeseeks_cli/src/meeseeks_cli/cli_master.py +++ b/apps/meeseeks_cli/src/meeseeks_cli/cli_master.py @@ -67,8 +67,8 @@ def _bootstrap_cli_logging_env(argv: list[str]) -> None: _bootstrap_cli_logging_env(sys.argv) -from meeseeks_core.classes import ActionStep, TaskQueue -from meeseeks_core.common import MockSpeaker, format_action_argument, get_logger +from meeseeks_core.classes import ActionStep, Plan, PlanStep, TaskQueue +from meeseeks_core.common import MockSpeaker, format_tool_input, get_logger from meeseeks_core.components import resolve_langfuse_status from meeseeks_core.config import ( AppConfig, @@ -118,16 +118,10 @@ def _resolve_session_id( ) -def _format_steps(steps: Iterable[ActionStep]) -> list[tuple[str, str, str]]: - rows: list[tuple[str, str, str]] = [] +def _format_steps(steps: Iterable[PlanStep]) -> list[tuple[str, str]]: + rows: list[tuple[str, str]] = [] for step in steps: - rows.append( - ( - step.action_consumer, - step.action_type, - format_action_argument(step.action_argument), - ) - ) + rows.append((step.title, step.description)) return rows @@ -522,16 +516,16 @@ def _run_query( args: argparse.Namespace, prompt_func: Callable[[str], str] | None, ) -> None: - initial_task_queue = None + initial_plan = None mode = _resolve_query_mode(query, state) if state.show_plan: - initial_task_queue = generate_action_plan( + initial_plan = generate_action_plan( user_query=query, model_name=state.model_name, session_summary=store.load_summary(state.session_id), mode=mode, ) - _render_plan_with_registry(console, initial_task_queue, tool_registry) + _render_plan_with_registry(console, initial_plan) auto_approve_enabled = bool( state.auto_approve_all or getattr(args, "auto_approve", False) or prompt_func is None @@ -558,7 +552,7 @@ def _run_query( user_query=query, model_name=state.model_name, max_iters=args.max_iters, - initial_task_queue=initial_task_queue, + initial_plan=initial_plan, session_id=state.session_id, tool_registry=tool_registry, approval_callback=approval_callback, @@ -697,28 +691,17 @@ def _tool_specs_by_id(tool_registry: ToolRegistry) -> dict[str, object]: def _render_plan_with_registry( console: Console, - task_queue: TaskQueue, - tool_registry: ToolRegistry, + plan: Plan, ) -> None: - specs = _tool_specs_by_id(tool_registry) lines: list[Text] = [] - for index, (tool, action, argument) in enumerate( - _format_steps(task_queue.action_steps), start=1 - ): - spec = specs.get(tool) - step = task_queue.action_steps[index - 1] - label = step.title or tool - if spec is not None and getattr(spec, "kind", "") == "mcp": - label = f"{label} (MCP)" + for index, (title, description) in enumerate(_format_steps(plan.steps), start=1): line = Text() line.append("[ ] ", style="dim") line.append(f"{index}. ", style="bold") - line.append(label, style="cyan") - line.append(" • ", style="dim") - line.append(action, style="magenta") - if argument: + line.append(title, style="cyan") + if description: line.append(" — ", style="dim") - line.append(argument) + line.append(description) lines.append(line) if not lines: lines.append(Text("No planned steps.", style="dim")) @@ -743,8 +726,8 @@ def _render_results_with_registry( steps = task_queue.action_steps last_index = len(steps) - 1 for index, step in enumerate(steps): - spec = specs.get(step.action_consumer) - label = step.action_consumer + spec = specs.get(step.tool_id) + label = step.tool_id if spec is not None and getattr(spec, "kind", "") == "mcp": label = f"{label} (MCP)" label = f":wrench: {label}" @@ -870,8 +853,8 @@ def _build_cli_hook_manager( specs = _tool_specs_by_id(tool_registry) def _start_spinner(action_step: ActionStep) -> ActionStep: - spec = specs.get(action_step.action_consumer) - label = action_step.action_consumer + spec = specs.get(action_step.tool_id) + label = action_step.tool_id if spec is not None and getattr(spec, "kind", "") == "mcp": label = f"{label} (MCP)" status = console.status(f"Running {label}...", spinner="dots") @@ -916,7 +899,7 @@ def _build_approval_callback( specs_by_id = _tool_specs_by_id(tool_registry) def _approve(action_step: ActionStep) -> bool: - spec = specs_by_id.get(action_step.action_consumer) + spec = specs_by_id.get(action_step.tool_id) is_mcp = spec is not None and getattr(spec, "kind", "") == "mcp" server_name = None tool_name = None @@ -933,8 +916,8 @@ def _approve(action_step: ActionStep) -> bool: pass subject = ( - f"{action_step.action_consumer}:{action_step.action_type} " - f"({format_action_argument(action_step.action_argument)})" + f"{action_step.tool_id}:{action_step.operation} " + f"({format_tool_input(action_step.tool_input)})" ) if approval_style in {"aider", "inline", "rich"}: rich_decision = _confirm_rich_panel( diff --git a/apps/meeseeks_cli/tests/test_cli.py b/apps/meeseeks_cli/tests/test_cli.py index 8978ab8f..dfb23989 100644 --- a/apps/meeseeks_cli/tests/test_cli.py +++ b/apps/meeseeks_cli/tests/test_cli.py @@ -5,7 +5,7 @@ import types from importlib.metadata import PackageNotFoundError -from meeseeks_core.classes import ActionStep, TaskQueue, set_available_tools # noqa: E402 +from meeseeks_core.classes import ActionStep, Plan, PlanStep, TaskQueue, set_available_tools # noqa: E402 from meeseeks_core.common import get_mock_speaker # noqa: E402 from meeseeks_core.config import get_config_value, set_config_override, set_mcp_config_path # noqa: E402 from meeseeks_core.session_runtime import SessionRuntime # noqa: E402 @@ -33,13 +33,15 @@ class DummyStep: - """Minimal action step stub for CLI formatting.""" + """Minimal plan step stub for CLI formatting.""" - def __init__(self, tool: str, action: str, argument: str) -> None: + def __init__(self, title: str, description: str, tool_input: str | None = None) -> None: """Initialize the dummy step.""" - self.action_consumer = tool - self.action_type = action - self.action_argument = argument + self.title = title + self.description = description + self.tool_id = title + self.operation = description + self.tool_input = "" if tool_input is None else tool_input def test_parse_command(): @@ -51,9 +53,9 @@ def test_parse_command(): def test_format_steps(): """Format action steps into display rows.""" - steps = [DummyStep("tool_a", "get", "status")] + steps = [DummyStep("Check status", "Verify status response.")] rows = _format_steps(steps) - assert rows == [("tool_a", "get", "status")] + assert rows == [("Check status", "Verify status response.")] def test_resolve_session_id(tmp_path): @@ -211,22 +213,17 @@ def test_run_query(monkeypatch, tmp_path): captured: dict[str, object] = {} def fake_generate(*args, **kwargs): - step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hi", + return Plan( + steps=[PlanStep(title="Check Home Assistant", description="Fetch status via HA.")] ) - task_queue = TaskQueue(action_steps=[step]) - task_queue.human_message = "hi" - return task_queue def fake_orchestrate(*args, **kwargs): captured["tool_registry"] = kwargs.get("tool_registry") captured["session_id"] = kwargs.get("session_id") step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hi", + tool_id="home_assistant_tool", + operation="get", + tool_input="hi", ) task_queue = TaskQueue(action_steps=[step]) task_queue.task_result = "ok" @@ -265,9 +262,9 @@ def fake_build(_prompt, _console, _state, _registry, *, auto_approve_enabled): def fake_orchestrate(*args, **kwargs): step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hi", + tool_id="home_assistant_tool", + operation="get", + tool_input="hi", ) task_queue = TaskQueue(action_steps=[step]) task_queue.task_result = "ok" @@ -316,15 +313,19 @@ def run(self, _step): ) ) - def fake_generate(_self, _query, *_args, **_kwargs): - step = ActionStep( - action_consumer="dummy_set_tool", - action_type="set", - action_argument="payload", + def fake_generate(*_args, **_kwargs): + return Plan(steps=[PlanStep(title="Run dummy tool", description="Execute the tool.")]) + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_set_tool", + args="payload", + response=None, ) - return TaskQueue(action_steps=[step]) monkeypatch.setattr("meeseeks_core.planning.Planner.generate", fake_generate) + monkeypatch.setattr("meeseeks_core.planning.StepExecutor.decide", fake_decide) monkeypatch.setattr( "meeseeks_core.orchestrator.Orchestrator._should_synthesize_response", lambda *_a, **_k: False, @@ -364,9 +365,9 @@ def test_run_query_renders_tool_output_and_response(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): step = ActionStep( - action_consumer="mcp_tool", - action_type="get", - action_argument="payload", + tool_id="mcp_tool", + operation="get", + tool_input="payload", ) step.result = get_mock_speaker()(content={"foo": "bar"}) task_queue = TaskQueue(action_steps=[step]) @@ -410,9 +411,9 @@ def test_run_query_renders_diff_tool_output(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): step = ActionStep( - action_consumer="diff_tool", - action_type="set", - action_argument="payload", + tool_id="diff_tool", + operation="set", + tool_input="payload", ) step.result = get_mock_speaker()( content={"kind": "diff", "text": "--- a/file.txt\n+++ b/file.txt\n"} @@ -458,9 +459,9 @@ def test_run_query_renders_shell_tool_output(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): step = ActionStep( - action_consumer="shell_tool", - action_type="get", - action_argument="payload", + tool_id="shell_tool", + operation="get", + tool_input="payload", ) step.result = get_mock_speaker()( content={ @@ -512,9 +513,9 @@ def test_run_query_hides_output_when_not_verbose(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): step = ActionStep( - action_consumer="mcp_tool", - action_type="get", - action_argument="payload", + tool_id="mcp_tool", + operation="get", + tool_input="payload", ) step.result = get_mock_speaker()(content={"foo": "bar"}) return TaskQueue(action_steps=[step]) @@ -563,14 +564,14 @@ def test_run_query_renders_partial_tool_results(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): first = ActionStep( - action_consumer="tool_one", - action_type="get", - action_argument="payload", + tool_id="tool_one", + operation="get", + tool_input="payload", ) second = ActionStep( - action_consumer="tool_two", - action_type="get", - action_argument="payload", + tool_id="tool_two", + operation="get", + tool_input="payload", ) second.result = get_mock_speaker()(content="ok") queue = TaskQueue(action_steps=[first, second]) @@ -607,9 +608,9 @@ def test_run_query_dims_tool_panels_after_response(monkeypatch, tmp_path): def fake_orchestrate(*_args, **_kwargs): step = ActionStep( - action_consumer="tool", - action_type="get", - action_argument="payload", + tool_id="tool", + operation="get", + tool_input="payload", ) step.result = get_mock_speaker()(content="ok") queue = TaskQueue(action_steps=[step]) @@ -730,9 +731,9 @@ def fake_header(*args, **kwargs): def fake_orchestrate(*args, **kwargs): step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hi", + tool_id="home_assistant_tool", + operation="get", + tool_input="hi", ) task_queue = TaskQueue(action_steps=[step]) task_queue.task_result = "ok" diff --git a/codecov.yml b/codecov.yml index 40b398d6..f2a0a868 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,7 +1,11 @@ coverage: status: + project: + default: + informational: true patch: default: + informational: true paths: - "packages/meeseeks_tools/src/meeseeks_tools/vendor/**" - "vendor/**" @@ -14,6 +18,7 @@ component_management: statuses: - type: project target: auto + informational: true individual_components: - component_id: core name: core diff --git a/configs/app.example.json b/configs/app.example.json index 7e694f84..cbf4de8b 100644 --- a/configs/app.example.json +++ b/configs/app.example.json @@ -1,6 +1,6 @@ { "runtime": { - "version": "2.1.0-alpha", + "version": "0.0.7", "envmode": "dev", "log_level": "INFO", "log_style": "", diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index e91c1e3a..91c57800 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -5,7 +5,7 @@ FROM python:3.11-slim-bookworm # Set the title, GitHub repo URL, version, and author ARG TITLE="Meeseeks Base" \ - VERSION="2.1.0-alpha" \ + VERSION="0.0.7" \ AUTHOR="Krishnakanth Alagiri" \ GITHUB_REPO_URL="https://github.com/bearlike/Assistant" diff --git a/docs/getting-started.md b/docs/getting-started.md index e0b576cc..64d09b93 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -90,7 +90,15 @@ API notes (polling is API-only; CLI uses the runtime in-process): - `GET /api/sessions?include_archived=1` include archived sessions - `POST /api/sessions/{session_id}/archive` archive a session - `DELETE /api/sessions/{session_id}/archive` unarchive a session - - `POST /api/query` legacy synchronous endpoint + - `POST /api/query` synchronous endpoint (simple/CLI-compatible) + - `GET /api/tools` list tool registry entries + - `GET /api/notifications` list notifications + - `POST /api/notifications/dismiss` dismiss notifications + - `POST /api/notifications/clear` clear notifications + - `POST /api/sessions/{session_id}/attachments` upload attachments + - `POST /api/sessions/{session_id}/share` create share link + - `POST /api/sessions/{session_id}/export` export session payload + - `GET /api/share/{token}` fetch shared session data ## Aider edit blocks (local tool) The edit-block tool expects strict SEARCH/REPLACE blocks and returns format guidance on mismatches. diff --git a/docs/index.md b/docs/index.md index 383f9eec..76145f74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,16 +18,17 @@ Meeseeks is an AI task agent assistant that breaks a request into small actions, - [Session runtime](session-runtime.md) - shared runtime used by CLI + API ## Feature highlights (quick view) -- Plan → act → observe loop to keep work grounded in tool results. +- Plan → tool selection → step execution loop to keep work grounded in tool results. - Multiple interfaces (chat UI, REST API, Home Assistant, terminal CLI) backed by one core engine. - Tool registry for local tools plus optional MCP tools. - Built-in local file and shell tools (Aider adapters) for edit blocks, read, list, and shell execution. - Session transcripts with compaction for long runs and context budget awareness. - Context snapshots built from recent turns plus summaries of prior activity. - Session listings filter empty sessions and support archiving via the API. -- Step-level reflection after tool execution to validate outcomes. +- Step-level reflection after tool execution to validate outcomes and adjust tool inputs. - Permission gate with approval callbacks plus lightweight hooks around tool execution. - Shared session runtime; API exposes polling endpoints while the CLI runs the runtime in-process for sync execution, cancellation, and summaries. +- Event payloads: `action_plan` steps are `{title, description}`, tool events use `tool_id`, `operation`, and `tool_input`. - Optional components (Langfuse, Home Assistant) auto-disable when not configured. - Langfuse tracing is session-scoped when enabled, grouping multi-turn runs. @@ -58,7 +59,9 @@ flowchart LR Runtime --> Core Runtime --> SessionStore Runtime --> Planner - Planner --> Tools + Planner --> ToolSelector + ToolSelector --> StepExecutor + StepExecutor --> Tools Tools --> LocalTools Tools --> MCP Tools --> HomeAssistant diff --git a/docs/session-runtime.md b/docs/session-runtime.md index b60e5671..b487857f 100644 --- a/docs/session-runtime.md +++ b/docs/session-runtime.md @@ -26,6 +26,10 @@ Typical polling flow: 2. Start an async run. 3. Poll `/events` with `after` to receive only new records. +Event payload notes: +- `action_plan` payloads include `steps: [{title, description}]`. +- Tool activity uses `tool_id`, `operation`, and `tool_input` in `tool_result` and `permission` events. + ## Minimal usage (Python) ```python from meeseeks_core.session_runtime import SessionRuntime diff --git a/meeseeks_ha_conversation/AGENTS.md b/meeseeks_ha_conversation/AGENTS.md index 54b82c72..ea417220 100644 --- a/meeseeks_ha_conversation/AGENTS.md +++ b/meeseeks_ha_conversation/AGENTS.md @@ -8,7 +8,7 @@ Scope: this file applies to `meeseeks_ha_conversation/` (home automation integra - Returns a parsed `MeeseeksQueryResponse` to the home automation host. ## Hidden dependencies / assumptions -- Uses a hardcoded API key in `MeeseeksApiClient` (`msk-strong-password`). Override if you change the API auth behavior. +- Uses a hardcoded API key in `MeeseeksApiClient` (`msk-strong-password`). Override for real deployments. - Assumes `base_url` includes protocol and is reachable from the host. - `async_get_models` is currently stubbed (static response). diff --git a/meeseeks_ha_conversation/README.md b/meeseeks_ha_conversation/README.md index 7c50cde3..40cb147b 100644 --- a/meeseeks_ha_conversation/README.md +++ b/meeseeks_ha_conversation/README.md @@ -16,8 +16,8 @@ -- Home Assistant Conversation Integration for Meeseeks. Can be used with HA Assist ⭐. -- Wrapped around the REST API Engine for Meeseeks. 100% coverage of Meeseeks API. +- Home Assistant Conversation integration for Meeseeks (works with HA Assist). +- Wrapped around the Meeseeks REST API for synchronous conversations. - This integration is optional and auto-disables if `home_assistant.enabled` is false or credentials are missing in `configs/app.json`. - No components are explicitly tested for safety or security. Use with caution in a production environment. - For full setup and configuration, see `docs/getting-started.md`. @@ -28,6 +28,6 @@ uv sync --extra ha ``` To use it in Home Assistant, install `meeseeks_ha_conversation/` as a custom component -and point it at the Meeseeks API. +and point it at the Meeseeks API URL + API key. [Link to GitHub Repository](https://github.com/bearlike/Assistant) diff --git a/meeseeks_ha_conversation/pyproject.toml b/meeseeks_ha_conversation/pyproject.toml index 701ee732..f891f33c 100644 --- a/meeseeks_ha_conversation/pyproject.toml +++ b/meeseeks_ha_conversation/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-ha-conversation" -version = "2.1.0-alpha" +version = "0.0.7" description = "Home Assistant conversation integration for Meeseeks." readme = "README.md" requires-python = ">=3.10,<4.0" diff --git a/packages/meeseeks_core/README.md b/packages/meeseeks_core/README.md new file mode 100644 index 00000000..16eae858 --- /dev/null +++ b/packages/meeseeks_core/README.md @@ -0,0 +1,27 @@ +# meeseeks-core + +Core orchestration engine for Meeseeks. This package owns the plan → tool selection → step execution loop, session storage, and event model shared by every interface. + +## What it provides +- Orchestrator + planning stages (`Planner`, `ToolSelector`, `StepExecutor`, `PlanUpdater`). +- Tool execution runner with permissions and hooks. +- Session runtime, transcripts (JSONL), summaries, and compaction. +- Event payloads used by the API and UIs (`action_plan`, `tool_result`, `permission`). + +## Key contracts +- `ActionStep` uses `tool_id`, `operation`, `tool_input` (no action_* fields). +- `action_plan` events emit `steps: [{title, description}]`. +- Tool events emit `tool_id`, `operation`, and `tool_input`. + +## Use in the monorepo +From the repo root: +```bash +uv sync +``` + +Then run an interface from `apps/` (CLI, API, chat UI) which imports this core. + +## Docs +- Root overview: `README.md` +- Setup: `docs/getting-started.md` +- Runtime: `docs/session-runtime.md` diff --git a/packages/meeseeks_core/pyproject.toml b/packages/meeseeks_core/pyproject.toml index b278375f..51dee9dd 100644 --- a/packages/meeseeks_core/pyproject.toml +++ b/packages/meeseeks_core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-core" -version = "2.1.0-alpha" +version = "0.0.7" description = "Core module for Meeseeks - orchestration, schemas, and utilities." readme = "../../README.md" requires-python = ">=3.10,<4.0" @@ -14,7 +14,7 @@ dependencies = [ "langchain>=1.0.0,<2.0.0", "langchain-core>=1.2.8,<2.0.0", "langchain-community>=0.4.1,<0.5.0", - "langchain-litellm>=0.4.0,<0.5.0", + "langchain-litellm>=0.4.0,<0.6.0", "langfuse>=3.8.0,<4.0.0", "litellm>=1.81.0,<2.0.0", "loguru>=0.7.2,<1.0.0", diff --git a/packages/meeseeks_core/src/meeseeks_core/action_runner.py b/packages/meeseeks_core/src/meeseeks_core/action_runner.py index ea536e0d..0d711b5a 100644 --- a/packages/meeseeks_core/src/meeseeks_core/action_runner.py +++ b/packages/meeseeks_core/src/meeseeks_core/action_runner.py @@ -70,7 +70,7 @@ def run(self, task_queue: TaskQueue) -> TaskQueue: logging.debug("Processing ActionStep: {}", action_step) if ( self._allowed_tool_ids is not None - and action_step.action_consumer not in self._allowed_tool_ids + and action_step.tool_id not in self._allowed_tool_ids ): reason = "tool not allowed in plan mode" self._record_failure(action_step, reason, task_queue) @@ -83,14 +83,14 @@ def run(self, task_queue: TaskQueue) -> TaskQueue: action_step = self._hook_manager.run_pre_tool_use(action_step) task_queue.action_steps[idx] = action_step - tool = self._tool_registry.get(action_step.action_consumer) + tool = self._tool_registry.get(action_step.tool_id) if tool is None: self._record_failure(action_step, "tool not available", task_queue) continue - spec = self._tool_registry.get_spec(action_step.action_consumer) + spec = self._tool_registry.get_spec(action_step.tool_id) if spec is not None: - schema_error = self._coerce_mcp_action_argument(action_step, spec) + schema_error = self._coerce_mcp_tool_input(action_step, spec) if schema_error: self._record_failure(action_step, schema_error, task_queue) self._emit_tool_result(action_step, None, error=schema_error) @@ -103,16 +103,21 @@ def run(self, task_queue: TaskQueue) -> TaskQueue: continue if outcome.reflection is not None and outcome.reflection.status != "ok": + status = outcome.reflection.status + reason = f"step reflection requested {status}" + if outcome.reflection.notes: + reason = f"{reason}: {outcome.reflection.notes}" + self._record_reflection_failure(action_step, reason, task_queue) + self._emit_tool_result(action_step, outcome.content, error=reason) if outcome.reflection.revised_argument: - action_step.action_argument = outcome.reflection.revised_argument - action_step.result = None + action_step.tool_input = outcome.reflection.revised_argument self._emit_event( { "type": "step_reflection", "payload": { - "action_consumer": action_step.action_consumer, - "action_type": action_step.action_type, - "action_argument": action_step.action_argument, + "tool_id": action_step.tool_id, + "operation": action_step.operation, + "tool_input": action_step.tool_input, "status": outcome.reflection.status, "notes": outcome.reflection.notes, }, @@ -137,8 +142,8 @@ def _ensure_permission(self, action_step: ActionStep) -> bool: decision_logged = False logging.debug( "Permission check: tool={} action={} decision={} callback_present={}", - action_step.action_consumer, - action_step.action_type, + action_step.tool_id, + action_step.operation, decision.value if isinstance(decision, PermissionDecision) else decision, self._approval_callback is not None, ) @@ -146,8 +151,8 @@ def _ensure_permission(self, action_step: ActionStep) -> bool: approved = self._approval_callback(action_step) if self._approval_callback else False logging.debug( "Permission prompt result: tool={} action={} approved={}", - action_step.action_consumer, - action_step.action_type, + action_step.tool_id, + action_step.operation, approved, ) decision = PermissionDecision.ALLOW if approved else PermissionDecision.DENY @@ -155,9 +160,9 @@ def _ensure_permission(self, action_step: ActionStep) -> bool: { "type": "permission", "payload": { - "action_consumer": action_step.action_consumer, - "action_type": action_step.action_type, - "action_argument": action_step.action_argument, + "tool_id": action_step.tool_id, + "operation": action_step.operation, + "tool_input": action_step.tool_input, "decision": decision.value, }, } @@ -165,18 +170,16 @@ def _ensure_permission(self, action_step: ActionStep) -> bool: decision_logged = True if decision == PermissionDecision.DENY: mock = get_mock_speaker() - message = ( - "Permission denied for " f"{action_step.action_consumer}:{action_step.action_type}." - ) + message = f"Permission denied for {action_step.tool_id}:{action_step.operation}." action_step.result = mock(content=message) if not decision_logged: self._emit_event( { "type": "permission", "payload": { - "action_consumer": action_step.action_consumer, - "action_type": action_step.action_type, - "action_argument": action_step.action_argument, + "tool_id": action_step.tool_id, + "operation": action_step.operation, + "tool_input": action_step.tool_input, "decision": decision.value, }, } @@ -185,7 +188,7 @@ def _ensure_permission(self, action_step: ActionStep) -> bool: return True def _execute_step(self, action_step: ActionStep) -> StepOutcome: - tool = self._tool_registry.get(action_step.action_consumer) + tool = self._tool_registry.get(action_step.tool_id) if tool is None: raise RuntimeError("Tool unavailable during execution") action_result = tool.run(action_step) @@ -204,16 +207,16 @@ def _handle_tool_error( ) -> None: logging.error("Error processing action step: {}", exc) self._record_failure(action_step, str(exc), task_queue) - spec = self._tool_registry.get_spec(action_step.action_consumer) + spec = self._tool_registry.get_spec(action_step.tool_id) is_mcp = spec is not None and spec.kind == "mcp" if not isinstance(exc, ToolInputError) and not is_mcp: - self._tool_registry.disable(action_step.action_consumer, f"Runtime error: {exc}") + self._tool_registry.disable(action_step.tool_id, f"Runtime error: {exc}") self._emit_tool_result(action_step, None, error=str(exc)) mock = get_mock_speaker() self._hook_manager.run_post_tool_use(action_step, mock(content=f"Tool error: {exc}")) def _record_failure(self, step: ActionStep, reason: str, task_queue: TaskQueue) -> None: - note = f"{step.action_consumer} ({step.action_type}) failed" + note = f"{step.tool_id} ({step.operation}) failed" if reason: note = f"{note}: {reason}" task_queue.last_error = note @@ -221,14 +224,22 @@ def _record_failure(self, step: ActionStep, reason: str, task_queue: TaskQueue) mock = get_mock_speaker() step.result = mock(content=f"ERROR: {reason}") + def _record_reflection_failure( + self, step: ActionStep, reason: str, task_queue: TaskQueue + ) -> None: + note = f"{step.tool_id} ({step.operation}) needs revision" + if reason: + note = f"{note}: {reason}" + task_queue.last_error = note + def _emit_tool_result( self, action_step: ActionStep, result: str | None, *, error: str | None = None ) -> None: summary = self._summarize_result(result, error) payload: ToolResultPayload = { - "action_consumer": action_step.action_consumer, - "action_type": action_step.action_type, - "action_argument": action_step.action_argument, + "tool_id": action_step.tool_id, + "operation": action_step.operation, + "tool_input": action_step.tool_input, "result": result, "success": error is None, "summary": summary, @@ -242,7 +253,7 @@ def _emit_event(self, event: Event) -> None: self._event_logger(event) @staticmethod - def _coerce_mcp_action_argument(action_step: ActionStep, spec: ToolSpec) -> str | None: + def _coerce_mcp_tool_input(action_step: ActionStep, spec: ToolSpec) -> str | None: if spec.kind != "mcp": return None schema = spec.metadata.get("schema") if spec.metadata else None @@ -254,7 +265,7 @@ def _coerce_mcp_action_argument(action_step: ActionStep, spec: ToolSpec) -> str properties = {} expected_fields = list(required) or list(properties.keys()) - argument = action_step.action_argument + argument = action_step.tool_input if isinstance(argument, str): stripped = argument.strip() if stripped.startswith("{") and stripped.endswith("}"): @@ -263,7 +274,7 @@ def _coerce_mcp_action_argument(action_step: ActionStep, spec: ToolSpec) -> str except json.JSONDecodeError: parsed = None if isinstance(parsed, dict): - action_step.action_argument = parsed + action_step.tool_input = parsed argument = parsed if isinstance(argument, str): if expected_fields: @@ -277,7 +288,7 @@ def _coerce_mcp_action_argument(action_step: ActionStep, spec: ToolSpec) -> str target_field = preferred break if target_field: - action_step.action_argument = {target_field: argument} + action_step.tool_input = {target_field: argument} return None fields = ", ".join(expected_fields) if expected_fields else "schema-defined fields" return f"Expected JSON object with fields: {fields}." @@ -305,12 +316,12 @@ def _coerce_mcp_action_argument(action_step: ActionStep, spec: ToolSpec) -> str and len(value) == 1 ): value = value[0] - action_step.action_argument = {required_field: value} + action_step.tool_input = {required_field: value} return None return f"Missing required fields: {', '.join(missing)}." return None - return "Unsupported action_argument type for MCP tool." + return "Unsupported tool_input type for MCP tool." @staticmethod def _summarize_result(result: str | None, error: str | None) -> str: @@ -331,7 +342,7 @@ def _format_step_summary(cls, step: ActionStep) -> str: summary = cls._summarize_result(str(content), None) if not summary: return "" - return f"{step.action_consumer}:{step.action_type} -> {summary}" + return f"{step.tool_id}:{step.operation} -> {summary}" __all__ = ["ActionPlanRunner", "EventLogger"] diff --git a/packages/meeseeks_core/src/meeseeks_core/classes.py b/packages/meeseeks_core/src/meeseeks_core/classes.py index 3cb6b514..a4e7efb6 100644 --- a/packages/meeseeks_core/src/meeseeks_core/classes.py +++ b/packages/meeseeks_core/src/meeseeks_core/classes.py @@ -17,7 +17,7 @@ from meeseeks_core.components import build_langfuse_handler from meeseeks_core.config import get_config_value from meeseeks_core.llm import build_chat_model -from meeseeks_core.types import ActionArgument, ActionStepPayload +from meeseeks_core.types import ActionStepPayload, ToolInput logging = get_logger(name="core.classes") AVAILABLE_TOOLS: list[str] = ["home_assistant_tool"] @@ -48,36 +48,59 @@ class ActionStep(BaseModel): default=None, description="Optional description of what success looks like.", ) - action_consumer: str = Field( + tool_id: str = Field( description=( "Specify the tool_id that should execute the action. " "Use only tool IDs listed under Available tools." ) ) - action_type: str = Field( - description="Specify either 'get' or 'set' to indicate the action type." - ) - action_argument: ActionArgument = Field( + operation: str = Field(description="Specify the execution type (get/set or execute).") + tool_input: ToolInput = Field( description=( "Provide details for the action. If 'task', specify the task to perform. " "If 'talk', include the message to speak to the user." ) ) - result: MockSpeaker | None = Field( + result: object | None = Field( alias="_result", default=None, description="Private field to persist the action status and other data.", ) + class Config: + """Allow both alias and field-name population.""" + + allow_population_by_field_name = True + extra = "forbid" + + +class PlanStep(BaseModel): + """High-level plan step produced by the planner.""" + + title: str = Field(description="Short title for the step.") + description: str = Field(description="One-paragraph description of the step.") + + +class Plan(BaseModel): + """Plan with human-readable steps.""" + + human_message: str | None = Field( + alias="_human_message", + default=None, + description="Human message associated with the plan.", + ) + steps: list[PlanStep] = Field(default_factory=list) + class TaskQueue(BaseModel): - """Queue of action steps and results.""" + """Queue of executed tool steps and results.""" human_message: str | None = Field( alias="_human_message", default=None, description="Human message associated with the task queue.", ) + plan_steps: list[PlanStep] = Field(default_factory=list) action_steps: list[ActionStep] = Field(default_factory=list) task_result: str | None = Field( alias="_task_result", default=None, description="Store the result for the entire task queue" @@ -93,21 +116,19 @@ class TaskQueue(BaseModel): def validate_actions(cls, field: list[ActionStep]) -> list[ActionStep]: """Normalize and validate action steps.""" for action in field: - action.action_consumer = action.action_consumer.lower() - action.action_type = action.action_type.lower() + action.tool_id = action.tool_id.lower() + action.operation = action.operation.lower() error_msg_list = [] - if action.action_consumer not in AVAILABLE_TOOLS: - error_msg_list.append( - f"`{action.action_consumer}` is not a valid Assistant consumer." - ) + if action.tool_id not in AVAILABLE_TOOLS: + error_msg_list.append(f"`{action.tool_id}` is not a valid Assistant tool.") - if action.action_type not in ["get", "set"]: - error_msg = f"`{action.action_type}` is not a valid action type." + if action.operation not in ["get", "set", "execute"]: + error_msg = f"`{action.operation}` is not a valid operation." error_msg_list.append(error_msg) - if action.action_argument is None: - error_msg_list.append("Action argument cannot be None.") + if action.tool_input is None: + error_msg_list.append("Tool input cannot be None.") if error_msg_list: for msg in error_msg_list: @@ -116,7 +137,7 @@ def validate_actions(cls, field: list[ActionStep]) -> list[ActionStep]: return field -ActionStep.update_forward_refs(ActionArgument=ActionArgument) +ActionStep.update_forward_refs(ToolInput=ToolInput) class OrchestrationState(BaseModel): @@ -124,7 +145,7 @@ class OrchestrationState(BaseModel): goal: str session_id: str | None = None - plan: list[ActionStep] = Field(default_factory=list) + plan: list[PlanStep] = Field(default_factory=list) tool_results: list[str] = Field(default_factory=list) open_questions: list[str] = Field(default_factory=list) done: bool = False @@ -213,12 +234,12 @@ def get_state(self, action_step: ActionStep | None = None) -> MockSpeaker: return MockSpeaker(content="Not implemented yet.") def run(self, action_step: ActionStep) -> MockSpeaker: - """Execute the action based on the action type.""" - if action_step.action_type == "set": + """Execute the action based on the operation.""" + if action_step.operation == "set": return self.set_state(action_step) - if action_step.action_type == "get": + if action_step.operation == "get": return self.get_state(action_step) - raise ValueError(f"Invalid action type: {action_step.action_type}") + raise ValueError(f"Invalid operation: {action_step.operation}") def create_task_queue( @@ -229,63 +250,51 @@ def create_task_queue( if action_data is None: raise ValueError("Action data cannot be None.") - # Convert the input data to ActionStep objects action_steps = [ActionStep(**action) for action in action_data] - # Create a TaskQueue object with the action steps task_queue = TaskQueue(action_steps=action_steps) if is_example: del task_queue.human_message return task_queue +def create_plan( + step_data: list[dict[str, str]] | None = None, + is_example: bool = True, +) -> Plan: + """Create a Plan from serialized step data.""" + if step_data is None: + raise ValueError("Step data cannot be None.") + steps = [PlanStep(**step) for step in step_data] + plan = Plan(steps=steps) + if is_example: + del plan.human_message + return plan + + def get_task_master_examples( example_id: int = 0, available_tools: Sequence[str] | None = None, ) -> str: - """Return serialized example task queue data.""" + """Return serialized example plan data.""" if available_tools is None: available_tools = AVAILABLE_TOOLS include_home_assistant = "home_assistant_tool" in available_tools if include_home_assistant: - examples: list[list[ActionStepPayload]] = [ + examples: list[list[dict[str, str]]] = [ [ { "title": "Turn on strip lights", - "objective": "Activate the strip lights via Home Assistant.", - "execution_checklist": [ - "Use Home Assistant set action", - "Target strip lights", - ], - "expected_output": "Strip lights are powered on.", - "action_consumer": "home_assistant_tool", - "action_type": "set", - "action_argument": "Power on the strip lights.", + "description": "Use Home Assistant to switch on the strip lights.", }, { "title": "Turn on heater", - "objective": "Activate the heater via Home Assistant.", - "execution_checklist": [ - "Use Home Assistant set action", - "Target heater", - ], - "expected_output": "Heater is powered on.", - "action_consumer": "home_assistant_tool", - "action_type": "set", - "action_argument": "Power on the Heater.", + "description": "Use Home Assistant to switch on the heater.", }, ], [ { "title": "Check weather", - "objective": "Retrieve today's weather from Home Assistant.", - "execution_checklist": [ - "Use Home Assistant get action", - "Ask for today's weather", - ], - "expected_output": "Weather details are returned.", - "action_consumer": "home_assistant_tool", - "action_type": "get", - "action_argument": "Get today's weather.", + "description": "Use Home Assistant to retrieve today's weather details.", }, ], ] @@ -294,4 +303,4 @@ def get_task_master_examples( if example_id not in range(0, len(examples)): raise ValueError(f"Invalid example ID: {example_id}") - return create_task_queue(action_data=examples[example_id], is_example=True).json() + return create_plan(step_data=examples[example_id], is_example=True).json() diff --git a/packages/meeseeks_core/src/meeseeks_core/common.py b/packages/meeseeks_core/src/meeseeks_core/common.py index 8c78b641..acdbefa7 100644 --- a/packages/meeseeks_core/src/meeseeks_core/common.py +++ b/packages/meeseeks_core/src/meeseeks_core/common.py @@ -5,8 +5,10 @@ import json import logging as logging_real +import os import sys import time +from contextlib import contextmanager from importlib import resources from typing import NamedTuple @@ -29,6 +31,7 @@ def get_mock_speaker() -> type[MockSpeaker]: _LOG_CONFIGURED = False +_SESSION_SINKS: dict[str, dict[str, int]] = {} def _resolve_log_level() -> str: @@ -76,6 +79,55 @@ def _configure_logging() -> None: _LOG_CONFIGURED = True +def _resolve_session_log_dir() -> str: + cache_dir = get_config_value("runtime", "cache_dir", default=".cache") + cache_dir = str(cache_dir or ".cache") + return os.path.join(cache_dir, "session-logs") + + +def _session_log_format() -> str: + return "{time:YYYY-MM-DD HH:mm:ss} [{extra[name]}] {level} {message}" + + +def _ensure_session_log_sink(session_id: str, log_dir: str | None = None) -> None: + _configure_logging() + if session_id in _SESSION_SINKS: + _SESSION_SINKS[session_id]["count"] += 1 + return + target_dir = log_dir or _resolve_session_log_dir() + os.makedirs(target_dir, exist_ok=True) + log_path = os.path.join(target_dir, f"{session_id}.log") + sink_id = loguru_logger.add( + log_path, + level=_resolve_log_level(), + format=_session_log_format(), + colorize=False, + filter=lambda record: record["extra"].get("session_id") == session_id, + ) + _SESSION_SINKS[session_id] = {"id": sink_id, "count": 1} + + +def _release_session_log_sink(session_id: str) -> None: + entry = _SESSION_SINKS.get(session_id) + if not entry: + return + entry["count"] -= 1 + if entry["count"] <= 0: + loguru_logger.remove(entry["id"]) + _SESSION_SINKS.pop(session_id, None) + + +@contextmanager +def session_log_context(session_id: str, log_dir: str | None = None): + """Context manager that logs all session output to a session log file.""" + _ensure_session_log_sink(session_id, log_dir=log_dir) + try: + with loguru_logger.contextualize(session_id=session_id): + yield + finally: + _release_session_log_sink(session_id) + + def get_logger(name: str | None = None): """Get the logger for the module.""" _configure_logging() @@ -114,11 +166,11 @@ def get_system_prompt(name: str = "action-planner") -> str: return system_prompt.strip() -def format_action_argument(argument: object) -> str: - """Format an action argument for logs and prompts.""" - if isinstance(argument, dict): - return json.dumps(argument, ensure_ascii=True) - return str(argument) +def format_tool_input(tool_input: object) -> str: + """Format a tool input for logs and prompts.""" + if isinstance(tool_input, dict): + return json.dumps(tool_input, ensure_ascii=True) + return str(tool_input) def ha_render_system_prompt( diff --git a/packages/meeseeks_core/src/meeseeks_core/config.py b/packages/meeseeks_core/src/meeseeks_core/config.py index 4c4ca19c..d94a224a 100644 --- a/packages/meeseeks_core/src/meeseeks_core/config.py +++ b/packages/meeseeks_core/src/meeseeks_core/config.py @@ -59,7 +59,7 @@ def _coerce_list(value: Any) -> list[str]: class RuntimeConfig(BaseModel): - version: str = Field("2.1.0-alpha", example="2.1.0-alpha") + version: str = Field("0.0.7", example="0.0.7") envmode: str = Field("dev", example="dev") log_level: str = Field("DEBUG", example="INFO") log_style: str = Field("", example="") diff --git a/packages/meeseeks_core/src/meeseeks_core/context.py b/packages/meeseeks_core/src/meeseeks_core/context.py index 83ad15d0..70b2a093 100644 --- a/packages/meeseeks_core/src/meeseeks_core/context.py +++ b/packages/meeseeks_core/src/meeseeks_core/context.py @@ -11,7 +11,7 @@ from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate from pydantic.v1 import BaseModel, Field -from meeseeks_core.common import format_action_argument, get_logger +from meeseeks_core.common import format_tool_input, get_logger from meeseeks_core.components import build_langfuse_handler, langfuse_trace_span from meeseeks_core.config import get_config_value from meeseeks_core.llm import build_chat_model @@ -44,9 +44,9 @@ def event_payload_text(event: EventRecord) -> str: """Return a readable payload string for an event.""" payload = event.get("payload", "") if isinstance(payload, dict): - if "action_argument" in payload: + if "tool_input" in payload: payload = dict(payload) - payload["action_argument"] = format_action_argument(payload.get("action_argument")) + payload["tool_input"] = format_tool_input(payload.get("tool_input")) return str( payload.get("text") or payload.get("message") or payload.get("result") or payload ) @@ -81,7 +81,15 @@ def build( events = self._session_store.load_transcript(session_id) summary = self._session_store.load_summary(session_id) context_events = [ - event for event in events if event.get("type") in {"user", "assistant", "tool_result"} + event + for event in events + if event.get("type") + in { + "user", + "assistant", + "tool_result", + "step_reflection", + } ] recent_limit = int(get_config_value("context", "recent_event_limit", default=8)) recent_events = context_events[-recent_limit:] if recent_limit > 0 else [] diff --git a/packages/meeseeks_core/src/meeseeks_core/notifications.py b/packages/meeseeks_core/src/meeseeks_core/notifications.py new file mode 100644 index 00000000..60872b8e --- /dev/null +++ b/packages/meeseeks_core/src/meeseeks_core/notifications.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Lightweight notification storage for Meeseeks.""" + +from __future__ import annotations + +import json +import os +import threading +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from meeseeks_core.config import get_config_value + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class NotificationRecord: + """Typed record for serialized notifications.""" + + id: str + title: str + message: str + level: str + created_at: str + dismissed: bool + session_id: str | None = None + dismissed_at: str | None = None + event_type: str | None = None + metadata: dict[str, object] | None = None + + +class NotificationStore: + """JSON-backed notification store for single-user UI.""" + + def __init__(self, root_dir: str | None = None, filename: str = "notifications.json") -> None: + """Initialize the notification store location.""" + if root_dir is None: + root_dir = get_config_value("runtime", "session_dir", default="./data/sessions") + root_dir = os.path.abspath(root_dir) + os.makedirs(root_dir, exist_ok=True) + self._path = os.path.join(root_dir, filename) + self._lock = threading.Lock() + + def _load(self) -> list[dict[str, object]]: + """Load notification records from disk.""" + if not os.path.exists(self._path): + return [] + with open(self._path, encoding="utf-8") as handle: + try: + data = json.load(handle) + except json.JSONDecodeError: + return [] + if isinstance(data, list): + return data + return [] + + def _save(self, data: list[dict[str, object]]) -> None: + """Persist notification records to disk.""" + with open(self._path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + + def list(self, *, include_dismissed: bool = False) -> list[dict[str, object]]: + """Return notifications, optionally including dismissed ones.""" + with self._lock: + data = self._load() + if not include_dismissed: + data = [item for item in data if not item.get("dismissed")] + return sorted( + data, + key=lambda item: str(item.get("created_at", "")), + reverse=True, + ) + + def add( + self, + *, + title: str, + message: str, + level: str = "info", + session_id: str | None = None, + event_type: str | None = None, + metadata: dict[str, object] | None = None, + ) -> dict[str, object]: + """Add a new notification record and return it.""" + record = NotificationRecord( + id=uuid.uuid4().hex, + title=title, + message=message, + level=level, + created_at=_utc_now(), + dismissed=False, + session_id=session_id, + dismissed_at=None, + event_type=event_type, + metadata=metadata, + ) + payload = record.__dict__ + with self._lock: + data = self._load() + data.append(payload) + self._save(data) + return payload + + def dismiss(self, ids: Sequence[str]) -> int: + """Mark notifications as dismissed.""" + if not ids: + return 0 + dismissed_at = _utc_now() + updated = 0 + with self._lock: + data = self._load() + for item in data: + if item.get("id") in ids and not item.get("dismissed"): + item["dismissed"] = True + item["dismissed_at"] = dismissed_at + updated += 1 + self._save(data) + return updated + + def clear(self, *, dismissed_only: bool = True) -> int: + """Clear dismissed notifications (or all when requested).""" + with self._lock: + data = self._load() + if dismissed_only: + remaining = [item for item in data if not item.get("dismissed")] + else: + remaining = [] + removed = len(data) - len(remaining) + self._save(remaining) + return removed + + +__all__ = ["NotificationStore", "NotificationRecord"] diff --git a/packages/meeseeks_core/src/meeseeks_core/orchestrator.py b/packages/meeseeks_core/src/meeseeks_core/orchestrator.py index 2b0fef05..8cb6e189 100644 --- a/packages/meeseeks_core/src/meeseeks_core/orchestrator.py +++ b/packages/meeseeks_core/src/meeseeks_core/orchestrator.py @@ -6,8 +6,8 @@ from collections.abc import Callable from meeseeks_core.action_runner import ActionPlanRunner -from meeseeks_core.classes import ActionStep, OrchestrationState, TaskQueue -from meeseeks_core.common import get_logger +from meeseeks_core.classes import ActionStep, OrchestrationState, Plan, PlanStep, TaskQueue +from meeseeks_core.common import get_logger, session_log_context from meeseeks_core.compaction import should_compact, summarize_events from meeseeks_core.components import langfuse_session_context from meeseeks_core.config import get_config_value @@ -18,12 +18,17 @@ approval_callback_from_config, load_permission_policy, ) -from meeseeks_core.planning import Planner, ResponseSynthesizer +from meeseeks_core.planning import ( + Planner, + PlanUpdater, + ResponseSynthesizer, + StepExecutor, + ToolSelector, +) from meeseeks_core.reflection import StepReflector from meeseeks_core.session_store import SessionStore from meeseeks_core.token_budget import get_token_budget -from meeseeks_core.tool_registry import ToolRegistry, load_registry -from meeseeks_core.types import ActionStepPayload +from meeseeks_core.tool_registry import ToolRegistry, ToolSpec, load_registry logging = get_logger(name="core.orchestrator") @@ -54,6 +59,9 @@ def __init__( self._hook_manager = hook_manager or default_hook_manager() self._context_builder = ContextBuilder(self._session_store) self._planner = Planner(self._tool_registry) + self._tool_selector = ToolSelector(self._tool_registry) + self._step_executor = StepExecutor(self._tool_registry) + self._plan_updater = PlanUpdater(self._tool_registry) self._synthesizer = ResponseSynthesizer(self._tool_registry) def run( @@ -61,7 +69,7 @@ def run( user_query: str, *, max_iters: int = 3, - initial_task_queue: TaskQueue | None = None, + initial_plan: Plan | None = None, return_state: bool = False, session_id: str | None = None, mode: str | None = None, @@ -71,23 +79,24 @@ def run( if session_id is None: session_id = self._session_store.create_session() - with langfuse_session_context(session_id): - return self._run_with_session_context( - user_query, - max_iters=max_iters, - initial_task_queue=initial_task_queue, - return_state=return_state, - session_id=session_id, - mode=mode, - should_cancel=should_cancel, - ) + with session_log_context(session_id): + with langfuse_session_context(session_id): + return self._run_with_session_context( + user_query, + max_iters=max_iters, + initial_plan=initial_plan, + return_state=return_state, + session_id=session_id, + mode=mode, + should_cancel=should_cancel, + ) def _run_with_session_context( self, user_query: str, *, max_iters: int, - initial_task_queue: TaskQueue | None, + initial_plan: Plan | None, return_state: bool, session_id: str, mode: str | None, @@ -99,118 +108,241 @@ def _run_with_session_context( state.summary = self._session_store.load_summary(session_id) state.tool_results = state.tool_results or [] state.open_questions = state.open_questions or [] + task_queue: TaskQueue | None = None - self._session_store.append_event( - session_id, {"type": "user", "payload": {"text": user_query}} - ) - - if self._should_update_summary(user_query): - state.summary = self._update_summary_with_memory( - session_id, - user_query.strip(), + try: + self._session_store.append_event( + session_id, {"type": "user", "payload": {"text": user_query}} ) - updated_summary = self._maybe_auto_compact(session_id) - if updated_summary: - state.summary = updated_summary - - if user_query.strip() == "/compact": - summary = summarize_events(self._session_store.load_transcript(session_id)) - self._session_store.save_summary(session_id, summary) - state.summary = summary - state.done = True - state.done_reason = "compacted" - task_queue = self._build_direct_response(f"Compaction complete. Summary: {summary}") - return (task_queue, state) if return_state else task_queue + if self._should_update_summary(user_query): + state.summary = self._update_summary_with_memory( + session_id, + user_query.strip(), + ) - context = self._context_builder.build( - session_id=session_id, - user_query=user_query, - model_name=self._model_name, - ) - task_queue = initial_task_queue or self._planner.generate( - user_query, self._model_name, context=context, mode=resolved_mode - ) - state.plan = task_queue.action_steps - self._append_action_plan(session_id, task_queue.action_steps) + updated_summary = self._maybe_auto_compact(session_id) + if updated_summary: + state.summary = updated_summary - for iteration in range(max_iters): - if should_cancel is not None and should_cancel(): + if user_query.strip() == "/compact": + summary = summarize_events(self._session_store.load_transcript(session_id)) + self._session_store.save_summary(session_id, summary) + state.summary = summary state.done = True - state.done_reason = "canceled" - break - task_queue = self._run_action_plan( - session_id, - task_queue, - mode=resolved_mode, - should_cancel=should_cancel, - ) - state.tool_results.append(task_queue.task_result or "") - if should_cancel is not None and should_cancel(): - state.done = True - state.done_reason = "canceled" - break + state.done_reason = "compacted" + task_queue = self._build_direct_response(f"Compaction complete. Summary: {summary}") + return (task_queue, state) if return_state else task_queue - if self._action_steps_complete(task_queue): - state.done = True - state.done_reason = "completed" - break - - if self._should_replan(task_queue, iteration, max_iters, mode=resolved_mode): - revised_query = self._build_revised_query(user_query, task_queue) - context = self._context_builder.build( - session_id=session_id, - user_query=revised_query, - model_name=self._model_name, + context = self._context_builder.build( + session_id=session_id, + user_query=user_query, + model_name=self._model_name, + ) + plan = initial_plan + tool_specs = ( + self._tool_registry.list_specs() + if resolved_mode == "plan" + else self._tool_registry.list_specs_for_mode("act") + ) + if plan is None: + if resolved_mode != "plan": + selection = self._tool_selector.select( + user_query, + self._model_name, + tool_specs=tool_specs, + context=context, + ) + if selection.tool_required and selection.tool_ids: + selected_ids = self._expand_tool_ids(set(selection.tool_ids), tool_specs) + tool_specs = [spec for spec in tool_specs if spec.tool_id in selected_ids] + plan = self._planner.generate( + user_query, + self._model_name, + context=context, + tool_specs=tool_specs, + mode=resolved_mode, ) - task_queue = self._planner.generate( - revised_query, self._model_name, context=context, mode=resolved_mode + if resolved_mode != "plan" and plan and self._plan_needs_verification(plan): + tool_specs = self._ensure_web_verification_tools( + tool_specs, + self._tool_registry.list_specs_for_mode("act"), ) - state.plan = task_queue.action_steps - self._append_action_plan(session_id, task_queue.action_steps) - else: + state.plan = plan.steps + self._append_action_plan(session_id, plan.steps) + + task_queue = TaskQueue(plan_steps=plan.steps, action_steps=[]) + tool_outputs: list[str] = [] + executed_steps: list[ActionStep] = [] + completed_steps: list[PlanStep] = [] + remaining_steps: list[PlanStep] = list(plan.steps) + last_error: str | None = None + direct_response: str | None = None + + if resolved_mode == "plan": state.done = True - state.done_reason = ( - "blocked" - if task_queue.last_error - and "permission denied" in task_queue.last_error.lower() - else "incomplete" + state.done_reason = "planned" + else: + max_steps = max(0, max_iters) * 5 + steps_run = 0 + allowed_tool_ids = {spec.tool_id for spec in tool_specs} + while remaining_steps and steps_run < max_steps: + if should_cancel is not None and should_cancel(): + state.done = True + state.done_reason = "canceled" + break + current_step = remaining_steps.pop(0) + decision = self._step_executor.decide( + user_query, + current_step, + self._model_name, + allowed_tools=tool_specs, + context=context, + ) + decision_type = (decision.decision or "").strip().lower() + if decision_type == "respond": + if decision.response: + direct_response = decision.response + tool_outputs.append(decision.response) + else: + last_error = "Step executor returned an empty response." + tool_outputs.append(f"ERROR: {last_error}") + completed_steps.append(current_step) + steps_run += 1 + state.done = True + state.done_reason = "completed" if direct_response else "incomplete" + break + elif decision_type == "tool": + tool_id = str(decision.tool_id or "").strip() + if not tool_id or tool_id not in allowed_tool_ids: + last_error = f"Tool '{tool_id or 'unknown'}' not allowed for this step." + tool_outputs.append(f"ERROR: {last_error}") + else: + args = decision.args + if args is None: + args = "" + elif not isinstance(args, (dict, str)): + args = str(args) + action_step = self._build_action_step( + current_step, + tool_id, + args, + ) + run_queue = TaskQueue(plan_steps=plan.steps, action_steps=[action_step]) + run_queue = self._run_action_plan( + session_id, + run_queue, + mode=resolved_mode, + should_cancel=should_cancel, + ) + executed_steps.extend(run_queue.action_steps) + if run_queue.task_result: + tool_outputs.append(run_queue.task_result) + if run_queue.last_error: + last_error = run_queue.last_error + else: + last_error = f"Invalid step decision: {decision.decision}" + tool_outputs.append(f"ERROR: {last_error}") + + completed_steps.append(current_step) + steps_run += 1 + if should_cancel is not None and should_cancel(): + state.done = True + state.done_reason = "canceled" + break + if remaining_steps: + remaining_steps = self._plan_updater.update( + user_query, + self._model_name, + completed_step=current_step, + last_result=tool_outputs[-1] if tool_outputs else None, + remaining_steps=remaining_steps, + context=context, + ) + state.plan = completed_steps + remaining_steps + self._append_action_plan(session_id, state.plan) + + if not state.done: + if remaining_steps and steps_run >= max_steps: + state.done_reason = "max_steps_reached" + elif last_error: + state.done_reason = ( + "blocked" if "permission denied" in last_error.lower() else "incomplete" + ) + else: + state.done_reason = "completed" + state.done = True + + task_queue.plan_steps = completed_steps + remaining_steps + task_queue.action_steps = executed_steps + task_queue.task_result = "\n".join(item for item in tool_outputs if item).strip() + task_queue.last_error = last_error + state.tool_results.extend(tool_outputs) + + if direct_response is not None and resolved_mode != "plan" and state.done: + task_queue.task_result = direct_response + self._session_store.append_event( + session_id, {"type": "assistant", "payload": {"text": direct_response}} + ) + elif ( + state.done + and resolved_mode != "plan" + and self._should_synthesize_response(task_queue) + ): + tool_outputs = tool_outputs or self._collect_tool_outputs(task_queue) + response = self._synthesizer.synthesize( + user_query=user_query, + tool_outputs=tool_outputs, + model_name=self._model_name, + context=context, + ) + task_queue.task_result = response + self._session_store.append_event( + session_id, {"type": "assistant", "payload": {"text": response}} ) - break - if state.done and resolved_mode != "plan" and self._should_synthesize_response(task_queue): - tool_outputs = self._collect_tool_outputs(task_queue) - response = self._synthesizer.synthesize( - user_query=user_query, - tool_outputs=tool_outputs, - model_name=self._model_name, - context=context, - ) - task_queue.task_result = response + if not state.done: # pragma: no cover - defensive guard + state.done_reason = "max_iterations_reached" + + completion_payload = { + "done": state.done, + "done_reason": state.done_reason, + "task_result": task_queue.task_result, + } + if task_queue.last_error: + completion_payload["error"] = task_queue.last_error + completion_payload["last_error"] = task_queue.last_error self._session_store.append_event( - session_id, {"type": "assistant", "payload": {"text": response}} + session_id, + {"type": "completion", "payload": completion_payload}, ) - if not state.done: - state.done_reason = "max_iterations_reached" + updated_summary = self._maybe_auto_compact(session_id) + if updated_summary: + state.summary = updated_summary - self._session_store.append_event( - session_id, - { - "type": "completion", - "payload": { - "done": state.done, - "done_reason": state.done_reason, - "task_result": task_queue.task_result, + return (task_queue, state) if return_state else task_queue + except Exception as exc: + logging.exception("Orchestration failed for session {}", session_id) + if task_queue is None: + task_queue = TaskQueue(_human_message=user_query, action_steps=[]) + task_queue.last_error = str(exc) + state.done = True + state.done_reason = "error" + self._session_store.append_event( + session_id, + { + "type": "completion", + "payload": { + "done": True, + "done_reason": state.done_reason, + "task_result": task_queue.task_result, + "error": str(exc), + "last_error": str(exc), + }, }, - }, - ) - - updated_summary = self._maybe_auto_compact(session_id) - if updated_summary: - state.summary = updated_summary - - return (task_queue, state) if return_state else task_queue + ) + return (task_queue, state) if return_state else task_queue def _run_action_plan( self, @@ -250,30 +382,116 @@ def _maybe_auto_compact(self, session_id: str) -> str | None: return summary return None - def _append_action_plan(self, session_id: str, steps: list[ActionStep]) -> None: - payload_steps: list[ActionStepPayload] = [ - self._serialize_action_step(step) for step in steps - ] + def _append_action_plan(self, session_id: str, steps: list[PlanStep]) -> None: + payload_steps = [self._serialize_plan_step(step) for step in steps] self._session_store.append_event( session_id, {"type": "action_plan", "payload": {"steps": payload_steps}} ) @staticmethod - def _serialize_action_step(step: ActionStep) -> ActionStepPayload: - payload: ActionStepPayload = { - "action_consumer": step.action_consumer, - "action_type": step.action_type, - "action_argument": step.action_argument, - } - if step.title: - payload["title"] = step.title - if step.objective: - payload["objective"] = step.objective - if step.execution_checklist: - payload["execution_checklist"] = step.execution_checklist - if step.expected_output: - payload["expected_output"] = step.expected_output - return payload + def _serialize_plan_step(step: PlanStep) -> dict[str, str]: + return {"title": step.title, "description": step.description} + + def _build_action_step( + self, + plan_step: PlanStep, + tool_id: str, + args: object | None, + ) -> ActionStep: + operation = self._infer_operation(tool_id) + return ActionStep( + title=plan_step.title, + objective=plan_step.description, + tool_id=tool_id, + operation=operation, + tool_input=args if args is not None else "", + ) + + @staticmethod + def _infer_operation(tool_id: str) -> str: + lowered = tool_id.lower() + write_keywords = [ + "set", + "edit", + "write", + "update", + "delete", + "create", + "apply", + "add", + "remove", + "patch", + "insert", + "append", + "replace", + "upload", + "post", + "put", + ] + if any(keyword in lowered for keyword in write_keywords): + return "set" + read_keywords = [ + "read", + "list", + "search", + "get", + "fetch", + "query", + "lookup", + "web_search", + "web_url_read", + ] + if any(keyword in lowered for keyword in read_keywords): + return "get" + return "get" + + @staticmethod + def _expand_tool_ids(selected_ids: set[str], tool_specs: list[ToolSpec]) -> set[str]: + if not selected_ids: + return selected_ids + lowered_selected = {tool_id.lower() for tool_id in selected_ids} + has_web_search = any( + key in tool_id + for tool_id in lowered_selected + for key in ("internet_search", "web_search", "searxng") + ) + if has_web_search: + for spec in tool_specs: + tool_id = spec.tool_id.lower() + if "web_url_read" in tool_id or "web_url" in tool_id or "web_read" in tool_id: + selected_ids.add(spec.tool_id) + return selected_ids + + @staticmethod + def _plan_needs_verification(plan: Plan) -> bool: + keywords = ("open", "verify", "read", "source", "citation", "citations") + for step in plan.steps: + combined = f"{step.title} {step.description}".lower() + if any(keyword in combined for keyword in keywords): + return True + return False + + @staticmethod + def _ensure_web_verification_tools( + selected: list[ToolSpec], + all_specs: list[ToolSpec], + ) -> list[ToolSpec]: + existing = {spec.tool_id for spec in selected} + needed = [] + for spec in all_specs: + if spec.tool_id in existing: + continue + tool_id = spec.tool_id.lower() + if ( + "internet_search" in tool_id + or "web_search" in tool_id + or "searxng" in tool_id + or "web_url_read" in tool_id + or "web_url" in tool_id + or "web_read" in tool_id + ): + needed.append(spec) + return selected + needed @staticmethod def _should_update_summary(text: str) -> bool: @@ -313,7 +531,9 @@ def _collect_tool_outputs(task_queue: TaskQueue) -> list[str]: continue content = getattr(step.result, "content", step.result) outputs.append(str(content)) - return outputs + if outputs or not task_queue.last_error: + return outputs + return [f"ERROR: {task_queue.last_error}"] @staticmethod def _should_synthesize_response(task_queue: TaskQueue) -> bool: @@ -321,12 +541,6 @@ def _should_synthesize_response(task_queue: TaskQueue) -> bool: return True return bool(Orchestrator._collect_tool_outputs(task_queue)) - @staticmethod - def _action_steps_complete(task_queue: TaskQueue) -> bool: - if task_queue.last_error: - return False - return all(step.result is not None for step in task_queue.action_steps) - @staticmethod def _build_revised_query(user_query: str, task_queue: TaskQueue) -> str: failure_note = ( diff --git a/packages/meeseeks_core/src/meeseeks_core/permissions.py b/packages/meeseeks_core/src/meeseeks_core/permissions.py index 5d9002a2..f9a17121 100644 --- a/packages/meeseeks_core/src/meeseeks_core/permissions.py +++ b/packages/meeseeks_core/src/meeseeks_core/permissions.py @@ -33,13 +33,13 @@ class PermissionRule: """Rule describing a tool/action permission decision.""" tool_id: str = "*" - action_type: str = "*" + operation: str = "*" decision: PermissionDecision = PermissionDecision.ASK def matches(self, action_step: ActionStep) -> bool: """Return True when the action step matches the rule pattern.""" - return fnmatch(action_step.action_consumer, self.tool_id) and fnmatch( - action_step.action_type, self.action_type + return fnmatch(action_step.tool_id, self.tool_id) and fnmatch( + action_step.operation, self.operation ) @@ -49,12 +49,12 @@ class PermissionPolicy: def __init__( self, rules: list[PermissionRule] | None = None, - default_by_action: dict[str, PermissionDecision] | None = None, + default_by_operation: dict[str, PermissionDecision] | None = None, default_decision: PermissionDecision = PermissionDecision.ASK, ) -> None: """Initialize the permission policy.""" self._rules = rules or [] - self._default_by_action = default_by_action or {} + self._default_by_operation = default_by_operation or {} self._default_decision = default_decision def decide(self, action_step: ActionStep) -> PermissionDecision: @@ -62,9 +62,9 @@ def decide(self, action_step: ActionStep) -> PermissionDecision: for rule in self._rules: if rule.matches(action_step): return rule.decision - action_decision = self._default_by_action.get(action_step.action_type) - if action_decision is not None: - return action_decision + operation_decision = self._default_by_operation.get(action_step.operation) + if operation_decision is not None: + return operation_decision return self._default_decision @@ -82,13 +82,13 @@ def _parse_decision(value: str | None) -> PermissionDecision | None: def _default_policy() -> PermissionPolicy: """Build the default permission policy.""" rules: list[PermissionRule] = [] - default_by_action = { + default_by_operation = { "get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK, } return PermissionPolicy( rules=rules, - default_by_action=default_by_action, + default_by_operation=default_by_operation, default_decision=PermissionDecision.ASK, ) @@ -124,16 +124,16 @@ def load_permission_policy(path: str | None = None) -> PermissionPolicy: rules.append( PermissionRule( tool_id=str(rule_data.get("tool_id", "*")), - action_type=str(rule_data.get("action_type", "*")), + operation=str(rule_data.get("operation", "*")), decision=decision, ) ) - default_by_action: dict[str, PermissionDecision] = {} - for key, value in payload.get("default_by_action", {}).items(): + default_by_operation: dict[str, PermissionDecision] = {} + for key, value in payload.get("default_by_operation", {}).items(): parsed = _parse_decision(str(value)) if parsed is not None: - default_by_action[str(key)] = parsed + default_by_operation[str(key)] = parsed default_decision = _parse_decision(payload.get("default_decision")) if default_decision is None: @@ -141,7 +141,7 @@ def load_permission_policy(path: str | None = None) -> PermissionPolicy: return PermissionPolicy( rules=rules, - default_by_action=default_by_action, + default_by_operation=default_by_operation, default_decision=default_decision, ) diff --git a/packages/meeseeks_core/src/meeseeks_core/planning.py b/packages/meeseeks_core/src/meeseeks_core/planning.py index ca294e6e..ca1cd068 100644 --- a/packages/meeseeks_core/src/meeseeks_core/planning.py +++ b/packages/meeseeks_core/src/meeseeks_core/planning.py @@ -3,14 +3,16 @@ from __future__ import annotations +import json import os from collections.abc import Iterable from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate +from pydantic.v1 import BaseModel, Field -from meeseeks_core.classes import TaskQueue, get_task_master_examples +from meeseeks_core.classes import Plan, PlanStep, get_task_master_examples from meeseeks_core.common import get_logger, get_system_prompt, num_tokens_from_string from meeseeks_core.components import ( ComponentStatus, @@ -22,12 +24,11 @@ from meeseeks_core.config import get_config_value from meeseeks_core.context import ContextSnapshot, render_event_lines from meeseeks_core.llm import build_chat_model -from meeseeks_core.tool_registry import ToolRegistry +from meeseeks_core.tool_registry import ToolRegistry, ToolSpec logging = get_logger(name="core.planning") EXAMPLE_TAG_OPEN = '' EXAMPLE_TAG_CLOSE = "" -TOOL_DETAIL_MAX = 10 INTENT_KEYWORDS: dict[str, set[str]] = { "web": { "latest", @@ -85,6 +86,29 @@ } +class ToolSelection(BaseModel): + """Tool selection decision for a user query.""" + + tool_required: bool = Field(default=False) + tool_ids: list[str] = Field(default_factory=list) + rationale: str | None = None + + +class StepDecision(BaseModel): + """Decision on how to execute a single plan step.""" + + decision: str = Field(description="tool or respond") + tool_id: str | None = None + args: object | None = None + response: str | None = None + + +class PlanUpdate(BaseModel): + """Updated remaining plan steps.""" + + steps: list[PlanStep] = Field(default_factory=list) + + class PromptBuilder: """Build system prompts with contextual sections.""" @@ -223,9 +247,10 @@ def generate( model_name: str, context: ContextSnapshot | None = None, *, + tool_specs: list[ToolSpec] | None = None, mode: str = "act", - ) -> TaskQueue: - """Generate a task queue from the user query.""" + ) -> Plan: + """Generate a plan from the user query.""" if self._tool_registry is None: raise ValueError("Tool registry is required for planning.") user_id = "meeseeks-task-master" @@ -242,13 +267,16 @@ def generate( openai_api_base=get_config_value("llm", "api_base"), api_key=get_config_value("llm", "api_key"), ) - parser = PydanticOutputParser(pydantic_object=TaskQueue) + parser = PydanticOutputParser(pydantic_object=Plan) component_status = self._resolve_component_status() - specs = self._tool_registry.list_specs_for_mode(mode) - include_tool_details = True - if mode == "act": + if tool_specs is not None: + specs = tool_specs + elif mode == "plan": + specs = self._tool_registry.list_specs() + else: + specs = self._tool_registry.list_specs_for_mode(mode) + if mode == "act" and tool_specs is None: specs = self._filter_specs_by_intent(specs, user_query) - include_tool_details = len(specs) <= TOOL_DETAIL_MAX available_tool_ids = [spec.tool_id for spec in specs] system_prompt = self._prompt_builder.build( get_system_prompt(), @@ -256,18 +284,14 @@ def generate( component_status=component_status if mode == "act" else None, mode=mode, tool_specs=specs, - include_tool_schemas=include_tool_details, - include_tool_guidance=include_tool_details, + include_tool_schemas=False, + include_tool_guidance=False, ) example_messages = self._build_example_messages(available_tool_ids, mode=mode) if mode == "act": - instruction = ( - "## Generate the minimal action plan for the user query\n" - "Prefer a single tool call when possible. " - "Avoid multi-step plans unless necessary." - ) + instruction = "## Generate the minimal plan for the user query" else: - instruction = "## Generate a task queue for the user query" + instruction = "## Generate a plan for the user query" prompt = ChatPromptTemplate( messages=[ SystemMessage(content=system_prompt), @@ -304,7 +328,7 @@ def generate( ) if span is not None: try: - span.update_trace(output={"step_count": len(action_plan.action_steps or [])}) + span.update_trace(output={"step_count": len(action_plan.steps or [])}) except Exception: pass action_plan.human_message = user_query @@ -353,6 +377,192 @@ def _resolve_component_status(self) -> list[ComponentStatus]: return [resolve_langfuse_status()] +def _render_tool_catalog(specs: list[ToolSpec], *, include_schema: bool) -> str: + lines: list[str] = [] + for spec in specs: + line = f"- {spec.tool_id}: {spec.description}" + if include_schema: + schema = spec.metadata.get("schema") + if isinstance(schema, dict): + line += f" | schema={json.dumps(schema, ensure_ascii=True)}" + lines.append(line) + return "\n".join(lines).strip() + + +class ToolSelector: + """Select tools needed to satisfy a request.""" + + def __init__(self, tool_registry: ToolRegistry | None) -> None: + """Initialize the tool selector.""" + self._tool_registry = tool_registry + + def select( + self, + user_query: str, + model_name: str, + *, + tool_specs: list[ToolSpec], + context: ContextSnapshot | None = None, + ) -> ToolSelection: + """Return a tool selection decision.""" + if self._tool_registry is None: + raise ValueError("Tool registry is required for tool selection.") + parser = PydanticOutputParser(pydantic_object=ToolSelection) # type: ignore[type-var] + system_prompt = get_system_prompt("tool-selector") + tools_text = _render_tool_catalog(tool_specs, include_schema=False) + prompt = ChatPromptTemplate( + messages=[ + SystemMessage( + content=( + f"{system_prompt}\n\nAvailable tools:\n{tools_text}" + if tools_text + else system_prompt + ) + ), + HumanMessagePromptTemplate.from_template( + "User request:\n{user_query}\n\n{format_instructions}" + ), + ], + partial_variables={"format_instructions": parser.get_format_instructions()}, + input_variables=["user_query"], + ) + try: + model = build_chat_model( + model_name=model_name, + openai_api_base=get_config_value("llm", "api_base"), + api_key=get_config_value("llm", "api_key"), + ) + selection = (prompt | model | parser).invoke({"user_query": user_query.strip()}) + if selection.tool_required and selection.tool_ids: + specs_by_id = {spec.tool_id: spec for spec in tool_specs} + selected_caps: set[str] = set() + for tool_id in selection.tool_ids: + spec = specs_by_id.get(tool_id) + if spec is not None: + selected_caps |= Planner._spec_capabilities(spec) + if "web_search" in selected_caps: + for spec in tool_specs: + if "web_read" in Planner._spec_capabilities(spec): + if spec.tool_id not in selection.tool_ids: + selection.tool_ids.append(spec.tool_id) + return selection + except Exception as exc: # pragma: no cover - defensive fallback + logging.warning("Tool selector unavailable, falling back to all tools: {}", exc) + return ToolSelection( + tool_required=bool(tool_specs), + tool_ids=[spec.tool_id for spec in tool_specs], + rationale="fallback", + ) + + +class StepExecutor: + """Decide how to execute a single plan step.""" + + def __init__(self, tool_registry: ToolRegistry | None) -> None: + """Initialize the step executor.""" + self._tool_registry = tool_registry + + def decide( + self, + user_query: str, + step: PlanStep, + model_name: str, + *, + allowed_tools: list[ToolSpec], + context: ContextSnapshot | None = None, + ) -> StepDecision: + """Return a decision for executing the step.""" + if self._tool_registry is None: + raise ValueError("Tool registry is required for step execution.") + parser = PydanticOutputParser(pydantic_object=StepDecision) # type: ignore[type-var] + system_prompt = get_system_prompt("step-executor") + tools_text = _render_tool_catalog(allowed_tools, include_schema=True) + prompt = ChatPromptTemplate( + messages=[ + SystemMessage( + content=( + f"{system_prompt}\n\nAllowed tools:\n{tools_text}" + if tools_text + else system_prompt + ) + ), + HumanMessagePromptTemplate.from_template( + "User request:\n{user_query}\n\n" + "Plan step:\n- {title}\n- {description}\n\n" + "{format_instructions}" + ), + ], + partial_variables={"format_instructions": parser.get_format_instructions()}, + input_variables=["user_query", "title", "description"], + ) + model = build_chat_model( + model_name=model_name, + openai_api_base=get_config_value("llm", "api_base"), + api_key=get_config_value("llm", "api_key"), + ) + decision = (prompt | model | parser).invoke( + { + "user_query": user_query.strip(), + "title": step.title, + "description": step.description, + } + ) + return decision + + +class PlanUpdater: + """Update remaining plan steps after executing a step.""" + + def __init__(self, tool_registry: ToolRegistry | None) -> None: + """Initialize the plan updater.""" + self._tool_registry = tool_registry + + def update( + self, + user_query: str, + model_name: str, + *, + completed_step: PlanStep, + last_result: str | None, + remaining_steps: list[PlanStep], + context: ContextSnapshot | None = None, + ) -> list[PlanStep]: + """Return updated remaining steps.""" + parser = PydanticOutputParser(pydantic_object=PlanUpdate) # type: ignore[type-var] + system_prompt = get_system_prompt("plan-updater") + remaining_lines = [f"- {step.title}: {step.description}" for step in remaining_steps] + remaining_text = "\n".join(remaining_lines) or "(none)" + prompt = ChatPromptTemplate( + messages=[ + SystemMessage(content=system_prompt), + HumanMessagePromptTemplate.from_template( + "User request:\n{user_query}\n\n" + "Completed step:\n- {title}\n- {description}\n\n" + "Latest result:\n{result}\n\n" + "Remaining steps:\n{remaining}\n\n" + "{format_instructions}" + ), + ], + partial_variables={"format_instructions": parser.get_format_instructions()}, + input_variables=["user_query", "title", "description", "result", "remaining"], + ) + model = build_chat_model( + model_name=model_name, + openai_api_base=get_config_value("llm", "api_base"), + api_key=get_config_value("llm", "api_key"), + ) + update = (prompt | model | parser).invoke( + { + "user_query": user_query.strip(), + "title": completed_step.title, + "description": completed_step.description, + "result": last_result or "", + "remaining": remaining_text, + } + ) + return update.steps + + class ResponseSynthesizer: """Synthesize a response from tool outputs.""" @@ -429,4 +639,14 @@ def synthesize( return str(content).strip() -__all__ = ["Planner", "PromptBuilder", "ResponseSynthesizer"] +__all__ = [ + "Planner", + "PromptBuilder", + "ResponseSynthesizer", + "ToolSelector", + "StepExecutor", + "PlanUpdater", + "ToolSelection", + "StepDecision", + "PlanUpdate", +] diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/action-planner.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/action-planner.txt index 47207beb..5bc25757 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/action-planner.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/action-planner.txt @@ -1,16 +1,11 @@ -You are Meeseeks, a task-completing agent running on a configured model. Create a task queue of atomic actions for the user request. Each action must include `action_consumer` and `action_argument`, plus: +You are Meeseeks, a task-completing agent running on a configured model. Create a high-level plan for the user request. Each step must include: - `title` (short header) -- `objective` (why this step) -- `execution_checklist` (3-6 short bullets) -- `expected_output` (optional) - -Use only tool IDs listed under "Available tools". If an MCP tool has an input schema, `action_argument` must be a JSON object that matches it. `action_type` must be "get" or "set". +- `description` (one paragraph describing the step) Guidelines: -- One task per action; keep steps crisp and relevant. -- Only add actions when a tool is required; otherwise return empty `action_steps`. -- Reflect on prior outcomes (including failures) and adjust the next step to reach the user's goal. -- Be transparent: name the tool(s) you will use and, if a tool fails or returns no data, add a step to inform the user and choose the next best action. -- Do not assume facts or fill in real data unless stated; plan to retrieve it via tools. -- If the user mentions local files, current directory, or \"pwd\", prefer local tools (Aider file read/list/edit) over GitHub MCP tools. +- Keep steps crisp and relevant. +- Include steps even if no tool is needed (e.g., “Answer directly”). +- Do not output tool calls or tool arguments; tool selection happens separately. +- If the user mentions local files, current directory, or \"pwd\", include a step to inspect local files. +- If the request needs external data, include a step to retrieve it. - Examples in the prompt are illustrative only; the actual user request begins at the final user query message. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/plan-updater.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/plan-updater.txt new file mode 100644 index 00000000..e49580a4 --- /dev/null +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/plan-updater.txt @@ -0,0 +1,9 @@ +You are Meeseeks. Decide whether to update the remaining plan steps based on the latest result. + +Return a PlanUpdate JSON object with: +- steps: the updated remaining steps (title + description) + +Rules: +- Only include remaining steps (do not repeat completed steps). +- If no changes are needed, return the same steps unchanged. +- Keep steps concise and in a logical order. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/step-executor.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/step-executor.txt new file mode 100644 index 00000000..74f53b92 --- /dev/null +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/step-executor.txt @@ -0,0 +1,13 @@ +You are Meeseeks. Decide how to execute one plan step. + +Return a StepDecision JSON object with: +- decision: "tool" or "respond" +- tool_id: required when decision="tool" +- args: required when decision="tool" (JSON object or string matching tool schema) +- response: required when decision="respond" + +Rules: +- Use only tool IDs listed under "Allowed tools". +- If a tool has a schema, args must match it. +- Prefer calling a tool when the step requires external information, file/system access, or verification. +- Respond directly only if you can answer from the given context or you need a single clarifying question from the user. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tool-selector.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tool-selector.txt new file mode 100644 index 00000000..ed7f4899 --- /dev/null +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tool-selector.txt @@ -0,0 +1,12 @@ +You are Meeseeks. Select which tools are required for the user request. + +Return a ToolSelection JSON object with: +- tool_required: true or false +- tool_ids: list of tool IDs to use (empty if none) +- rationale: optional short reason + +Rules: +- Use only tool IDs listed under "Available tools". +- Prefer the smallest set of tools that can accomplish the request. +- Do not provide tool arguments or steps here. +- If uncertain, set tool_required=true and include likely tools instead of returning an empty set. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-edit-blocks.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-edit-blocks.txt index 73d28161..7e56d3a0 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-edit-blocks.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-edit-blocks.txt @@ -20,9 +20,9 @@ Rules: - To create a new file, leave SEARCH empty and put full content in REPLACE. - Do not include shell code blocks; use the shell tool for commands. -Action arguments (JSON): +Tool inputs (JSON): - content: string containing one or more SEARCH/REPLACE blocks. - root: optional project root to resolve file paths (defaults to current working directory). - files: optional list of file paths to validate fuzzy filename matches. -Use action_type "set" to apply changes and "get" to validate without writing. +Provide the arguments required to apply edits or validate without writing. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-list-dir.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-list-dir.txt index 91545db4..8905d4cf 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-list-dir.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-list-dir.txt @@ -2,9 +2,9 @@ Aider List Directory tool Use this tool to list files under a local directory. -Action arguments (JSON): +Tool inputs (JSON): - path: directory or file path to list (defaults to "."). - root: optional project root to resolve file paths (defaults to current working directory). - max_entries: optional integer limit for number of entries returned. -Use action_type "get" to list entries. +Provide the arguments required to list entries. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-read-file.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-read-file.txt index b3c596a0..5a6e004e 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-read-file.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-read-file.txt @@ -2,9 +2,9 @@ Aider Read File tool Use this tool to read local files. -Action arguments (JSON): +Tool inputs (JSON): - path: file path to read (required). - root: optional project root to resolve file paths (defaults to current working directory). - max_bytes: optional integer limit for truncating large files. -Use action_type "get" to read the file. +Provide the arguments required to read the file. diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-shell.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-shell.txt index 570db3cc..a1954215 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-shell.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/aider-shell.txt @@ -9,4 +9,4 @@ Guidelines: - Do not run destructive commands without explicit user intent. Example: -ActionStep(action_consumer="aider_shell_tool", action_type="set", action_argument={"command": "ls", "cwd": "."}) +Call `aider_shell_tool` with args: {"command": "ls", "cwd": "."} diff --git a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/home-assistant.txt b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/home-assistant.txt index a8a35497..c91ec459 100644 --- a/packages/meeseeks_core/src/meeseeks_core/prompts/tools/home-assistant.txt +++ b/packages/meeseeks_core/src/meeseeks_core/prompts/tools/home-assistant.txt @@ -1,10 +1,10 @@ -Tool: Home Assistant (action_consumer="home_assistant_tool") +Tool: Home Assistant (tool_id="home_assistant_tool") - This API manages smart home devices by calling services within domains like "light" or "switch". - For example, the "turn_on" service in the "light" domain can turn on a specified light by passing its ID as service_data. - The API also provides scenes to automate tasks and returns a list of states that changed during the service execution. - This API should contain most user's current information available as sensors. -- action_type=set: Change the state of a Home Assistant device or entity. -- action_type=get: Directly talks to the user about information revolving sensors and devices within Home Assistant. +- Change the state of a Home Assistant device or entity. +- Ask for information about sensors and devices within Home Assistant. - Information on weather, servers, and self-hosted services can be found in Home Assistant. - If the user is requesting information about their own details, you can try accessing Home Assistant. Even if you're unsure if Home Assistant has access to a sensor, you can still query it and it will return an error to the user. diff --git a/packages/meeseeks_core/src/meeseeks_core/reflection.py b/packages/meeseeks_core/src/meeseeks_core/reflection.py index b8909274..4f2022df 100644 --- a/packages/meeseeks_core/src/meeseeks_core/reflection.py +++ b/packages/meeseeks_core/src/meeseeks_core/reflection.py @@ -12,7 +12,7 @@ from pydantic.v1 import BaseModel, Field from meeseeks_core.classes import ActionStep -from meeseeks_core.common import format_action_argument, get_logger +from meeseeks_core.common import format_tool_input, get_logger from meeseeks_core.components import build_langfuse_handler, langfuse_trace_span from meeseeks_core.config import get_config_value from meeseeks_core.llm import build_chat_model @@ -58,7 +58,7 @@ def reflect(self, action_step: ActionStep, result_text: str) -> StepReflection | content=( "Reflect on whether the tool result satisfies the step objective. " "Return status 'ok' if complete, 'retry' if the step should be " - "re-executed, or 'revise' if the action argument needs adjustment." + "re-executed, or 'revise' if the tool input needs adjustment." ) ), HumanMessagePromptTemplate.from_template( @@ -73,11 +73,15 @@ def reflect(self, action_step: ActionStep, result_text: str) -> StepReflection | partial_variables={"format_instructions": parser.get_format_instructions()}, input_variables=["title", "objective", "checklist", "expected", "result"], ) - model = build_chat_model( - model_name=reflection_model, - openai_api_base=get_config_value("llm", "api_base"), - api_key=get_config_value("llm", "api_key"), - ) + try: + model = build_chat_model( + model_name=reflection_model, + openai_api_base=get_config_value("llm", "api_base"), + api_key=get_config_value("llm", "api_key"), + ) + except Exception as exc: + logging.warning("Step reflection unavailable: {}", exc) + return None handler = build_langfuse_handler( user_id="meeseeks-reflection", session_id=f"reflection-{os.getpid()}-{os.urandom(4).hex()}", @@ -97,18 +101,18 @@ def reflect(self, action_step: ActionStep, result_text: str) -> StepReflection | try: span.update_trace( input={ - "title": action_step.title or action_step.action_consumer, + "title": action_step.title or action_step.tool_id, "objective": action_step.objective - or format_action_argument(action_step.action_argument), + or format_tool_input(action_step.tool_input), } ) except Exception: pass reflection = (prompt | model | parser).invoke( { - "title": action_step.title or action_step.action_consumer, + "title": action_step.title or action_step.tool_id, "objective": action_step.objective - or format_action_argument(action_step.action_argument), + or format_tool_input(action_step.tool_input), "checklist": "; ".join(action_step.execution_checklist or []), "expected": action_step.expected_output or "Not specified", "result": result_text, diff --git a/packages/meeseeks_core/src/meeseeks_core/session_runtime.py b/packages/meeseeks_core/src/meeseeks_core/session_runtime.py index 12bd78a1..30d2296c 100644 --- a/packages/meeseeks_core/src/meeseeks_core/session_runtime.py +++ b/packages/meeseeks_core/src/meeseeks_core/session_runtime.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timezone -from meeseeks_core.classes import TaskQueue +from meeseeks_core.classes import Plan, TaskQueue from meeseeks_core.session_store import SessionStore from meeseeks_core.task_master import orchestrate_session from meeseeks_core.types import EventRecord @@ -236,7 +236,7 @@ def start_async( user_query: str, model_name: str | None = None, max_iters: int = 3, - initial_task_queue: TaskQueue | None = None, + initial_plan: Plan | None = None, tool_registry=None, permission_policy=None, approval_callback=None, @@ -251,7 +251,7 @@ def _run(cancel_event: threading.Event) -> None: session_id=session_id, model_name=model_name, max_iters=max_iters, - initial_task_queue=initial_task_queue, + initial_plan=initial_plan, tool_registry=tool_registry, permission_policy=permission_policy, approval_callback=approval_callback, @@ -269,7 +269,7 @@ def run_sync( session_id: str, model_name: str | None = None, max_iters: int = 3, - initial_task_queue: TaskQueue | None = None, + initial_plan: Plan | None = None, tool_registry=None, permission_policy=None, approval_callback=None, @@ -282,7 +282,7 @@ def run_sync( user_query=user_query, model_name=model_name, max_iters=max_iters, - initial_task_queue=initial_task_queue, + initial_plan=initial_plan, session_id=session_id, session_store=self._session_store, tool_registry=tool_registry, diff --git a/packages/meeseeks_core/src/meeseeks_core/share_store.py b/packages/meeseeks_core/src/meeseeks_core/share_store.py new file mode 100644 index 00000000..d9a9f588 --- /dev/null +++ b/packages/meeseeks_core/src/meeseeks_core/share_store.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Session share token storage.""" + +from __future__ import annotations + +import json +import os +import threading +import uuid +from datetime import datetime, timezone + +from meeseeks_core.config import get_config_value + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class ShareStore: + """JSON-backed share token store for session exports.""" + + def __init__(self, root_dir: str | None = None, filename: str = "shares.json") -> None: + """Initialize the share token store location.""" + if root_dir is None: + root_dir = get_config_value("runtime", "session_dir", default="./data/sessions") + root_dir = os.path.abspath(root_dir) + os.makedirs(root_dir, exist_ok=True) + self._path = os.path.join(root_dir, filename) + self._lock = threading.Lock() + + def _load(self) -> dict[str, dict[str, object]]: + """Load share token records from disk.""" + if not os.path.exists(self._path): + return {} + with open(self._path, encoding="utf-8") as handle: + try: + data = json.load(handle) + except json.JSONDecodeError: + return {} + if isinstance(data, dict): + return data + return {} + + def _save(self, data: dict[str, dict[str, object]]) -> None: + """Persist share token records to disk.""" + with open(self._path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + + def create(self, session_id: str) -> dict[str, object]: + """Create and store a new share token.""" + token = uuid.uuid4().hex + record: dict[str, object] = {"session_id": session_id, "created_at": _utc_now()} + with self._lock: + data = self._load() + data[token] = record + self._save(data) + return {"token": token, **record} + + def resolve(self, token: str) -> dict[str, object] | None: + """Resolve a share token to its record.""" + if not token: + return None + with self._lock: + data = self._load() + record = data.get(token) + if not record: + return None + return {"token": token, **record} + + def revoke(self, token: str) -> bool: + """Revoke a share token.""" + if not token: + return False + with self._lock: + data = self._load() + if token not in data: + return False + data.pop(token, None) + self._save(data) + return True + + +__all__ = ["ShareStore"] diff --git a/packages/meeseeks_core/src/meeseeks_core/task_master.py b/packages/meeseeks_core/src/meeseeks_core/task_master.py index 14e87edc..f6b7d351 100644 --- a/packages/meeseeks_core/src/meeseeks_core/task_master.py +++ b/packages/meeseeks_core/src/meeseeks_core/task_master.py @@ -10,7 +10,7 @@ from langchain_core._api.beta_decorator import LangChainBetaWarning from meeseeks_core.action_runner import ActionPlanRunner -from meeseeks_core.classes import ActionStep, OrchestrationState, TaskQueue +from meeseeks_core.classes import ActionStep, OrchestrationState, Plan, TaskQueue from meeseeks_core.common import get_logger from meeseeks_core.config import get_config_value from meeseeks_core.context import ContextSnapshot @@ -57,8 +57,8 @@ def generate_action_plan( selected_events: list[EventRecord] | None = None, *, mode: str = "act", -) -> TaskQueue: - """Generate an action plan for a user query.""" +) -> Plan: + """Generate a plan for a user query.""" tool_registry = tool_registry or load_registry() resolved_model = cast( str, @@ -107,7 +107,7 @@ def orchestrate_session( user_query: str, model_name: str | None = None, max_iters: int = 3, - initial_task_queue: TaskQueue | None = None, + initial_plan: Plan | None = None, return_state: bool = False, session_id: str | None = None, session_store: SessionStore | None = None, @@ -129,7 +129,7 @@ def orchestrate_session( ).run( user_query, max_iters=max_iters, - initial_task_queue=initial_task_queue, + initial_plan=initial_plan, return_state=return_state, session_id=session_id, mode=mode, diff --git a/packages/meeseeks_core/src/meeseeks_core/types.py b/packages/meeseeks_core/src/meeseeks_core/types.py index 26ed2dc1..c65895c2 100644 --- a/packages/meeseeks_core/src/meeseeks_core/types.py +++ b/packages/meeseeks_core/src/meeseeks_core/types.py @@ -8,42 +8,49 @@ from typing_extensions import NotRequired JsonValue = str | int | float | bool | None | list[object] | dict[str, object] -ActionArgument = str | dict[str, object] +ToolInput = str | dict[str, object] -class ActionStepPayload(TypedDict): - """Serialized action step data sent to/from orchestration.""" +class PlanStepPayload(TypedDict): + """Payload describing a single plan step.""" - action_consumer: str - action_type: str - action_argument: ActionArgument - title: NotRequired[str] - objective: NotRequired[str] - execution_checklist: NotRequired[list[str]] - expected_output: NotRequired[str] + title: str + description: str class ActionPlanPayload(TypedDict): """Payload describing an action plan.""" - steps: list[ActionStepPayload] + steps: list[PlanStepPayload] + + +class ActionStepPayload(TypedDict): + """Serialized tool call data sent to/from execution.""" + + tool_id: str + operation: str + tool_input: ToolInput + title: NotRequired[str] + objective: NotRequired[str] + execution_checklist: NotRequired[list[str]] + expected_output: NotRequired[str] class PermissionPayload(TypedDict): """Payload emitted for permission decisions.""" - action_consumer: str - action_type: str - action_argument: str + tool_id: str + operation: str + tool_input: str decision: str class ToolResultPayload(TypedDict): """Payload describing the outcome of a tool invocation.""" - action_consumer: str - action_type: str - action_argument: ActionArgument + tool_id: str + operation: str + tool_input: ToolInput result: str | None success: NotRequired[bool] summary: NotRequired[str] @@ -68,6 +75,8 @@ class CompletionPayload(TypedDict): done: bool done_reason: str | None task_result: str | None + error: NotRequired[str] + last_error: NotRequired[str] EventPayload = ( diff --git a/packages/meeseeks_tools/README.md b/packages/meeseeks_tools/README.md new file mode 100644 index 00000000..e353f5f5 --- /dev/null +++ b/packages/meeseeks_tools/README.md @@ -0,0 +1,20 @@ +# meeseeks-tools + +Tool implementations and integrations for Meeseeks. This package ships the built‑in local tools (file read/list/edit, shell) plus integrations for Home Assistant and MCP. + +## What it provides +- Aider-based local tools for file reads, directory listing, edit blocks, and shell commands. +- MCP tool integration for remote tool servers. +- Home Assistant tool adapter (used by the HA conversation integration). + +## Use in the monorepo +From the repo root: +```bash +uv sync --extra tools +``` + +Then run an interface from `apps/` (CLI, API, chat UI), which will load tools via `ToolRegistry`. + +## Notes +- Tool inputs are passed via `tool_input` (string or JSON object). +- Tool results surface through `tool_result` events with `tool_id` and `operation`. diff --git a/packages/meeseeks_tools/pyproject.toml b/packages/meeseeks_tools/pyproject.toml index 7ef0cf84..adb80657 100644 --- a/packages/meeseeks_tools/pyproject.toml +++ b/packages/meeseeks_tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-tools" -version = "2.1.0-alpha" +version = "0.0.7" description = "Tool implementations and integrations for Meeseeks." readme = "../../README.md" requires-python = ">=3.10,<4.0" @@ -10,7 +10,7 @@ authors = [ license = { text = "MIT" } dependencies = [ - "meeseeks-core>=2.1.0-alpha", + "meeseeks-core>=0.0.7", "langchain-mcp-adapters>=0.2.1,<0.3.0", "prompt-toolkit>=3.0.47,<4.0.0", "pygments>=2.17.0,<3.0.0", diff --git a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_edit_blocks.py b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_edit_blocks.py index 76cac636..60485052 100644 --- a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_edit_blocks.py +++ b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_edit_blocks.py @@ -91,7 +91,7 @@ def _parse_request(action_step: ActionStep | None) -> EditBlockRequest: if action_step is None: raise EditBlockApplyError("Action step is required for edit block operations.") - argument = action_step.action_argument + argument = action_step.tool_input if isinstance(argument, str): return EditBlockRequest(content=argument, root=os.getcwd(), files=None) @@ -108,7 +108,7 @@ def _parse_request(action_step: ActionStep | None) -> EditBlockRequest: raise EditBlockApplyError("files must be a list of strings.") return EditBlockRequest(content=content, root=root, files=files) - raise EditBlockApplyError("Action argument must be a string or object payload.") + raise EditBlockApplyError("Tool input must be a string or object payload.") def _collect_target_paths(request: EditBlockRequest) -> dict[str, Path]: diff --git a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_file_tools.py b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_file_tools.py index 51f58ed4..b32d8556 100644 --- a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_file_tools.py +++ b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_file_tools.py @@ -44,7 +44,7 @@ def _resolve_path(root: str, rel_path: str) -> Path: def _parse_read_request(action_step: ActionStep | None) -> ReadFileRequest: if action_step is None: raise ValueError("Action step is required.") - argument = action_step.action_argument + argument = action_step.tool_input if isinstance(argument, str): path = argument.strip() if not path: @@ -62,13 +62,13 @@ def _parse_read_request(action_step: ActionStep | None) -> ReadFileRequest: except (TypeError, ValueError): max_bytes = None return ReadFileRequest(path=path, root=root, max_bytes=max_bytes) - raise ValueError("Action argument must be a string path or an object payload.") + raise ValueError("Tool input must be a string path or an object payload.") def _parse_list_request(action_step: ActionStep | None) -> ListDirRequest: if action_step is None: raise ValueError("Action step is required.") - argument = action_step.action_argument + argument = action_step.tool_input if isinstance(argument, str): path = argument.strip() or "." return ListDirRequest(path=path, root=os.getcwd(), max_entries=None) @@ -82,7 +82,7 @@ def _parse_list_request(action_step: ActionStep | None) -> ListDirRequest: except (TypeError, ValueError): max_entries = None return ListDirRequest(path=path, root=root, max_entries=max_entries) - raise ValueError("Action argument must be a string path or an object payload.") + raise ValueError("Tool input must be a string path or an object payload.") class AiderReadFileTool(AbstractTool): diff --git a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_shell_tool.py b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_shell_tool.py index 62ec654f..f811e71f 100644 --- a/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_shell_tool.py +++ b/packages/meeseeks_tools/src/meeseeks_tools/integration/aider_shell_tool.py @@ -35,7 +35,7 @@ def _resolve_cwd(root: str, cwd: str | None) -> str: def _parse_shell_request(action_step: ActionStep | None) -> ShellRequest: if action_step is None: raise ValueError("Action step is required.") - argument = action_step.action_argument + argument = action_step.tool_input if isinstance(argument, str): command = argument.strip() if not command: @@ -48,7 +48,7 @@ def _parse_shell_request(action_step: ActionStep | None) -> ShellRequest: root = str(argument.get("root") or os.getcwd()) cwd = _resolve_cwd(root, argument.get("cwd")) return ShellRequest(command=command, cwd=cwd) - raise ValueError("Action argument must be a string command or an object payload.") + raise ValueError("Tool input must be a string command or an object payload.") def _run_command(command: str, cwd: str) -> tuple[int, str]: diff --git a/packages/meeseeks_tools/src/meeseeks_tools/integration/homeassistant.py b/packages/meeseeks_tools/src/meeseeks_tools/integration/homeassistant.py index 3accde5a..200a3bac 100644 --- a/packages/meeseeks_tools/src/meeseeks_tools/integration/homeassistant.py +++ b/packages/meeseeks_tools/src/meeseeks_tools/integration/homeassistant.py @@ -562,7 +562,7 @@ def _invoke_service_and_set_state( MockSpeaker = get_mock_speaker() try: - action_step_curr = str(action_step.action_argument).strip() + action_step_curr = str(action_step.tool_input).strip() call_service_values = chain.invoke( {"action_step": action_step_curr, "context": rag_documents, "cache": self.cache}, ) @@ -644,7 +644,7 @@ def get_state(self, action_step: ActionStep | None = None) -> MockSpeaker: logging.info("Invoking `get` action chain using `{}`.", self.model_name) message = chain.invoke( { - "action_step": str(action_step.action_argument).strip(), + "action_step": str(action_step.tool_input).strip(), "context": rag_documents, }, ) diff --git a/packages/meeseeks_tools/src/meeseeks_tools/integration/mcp.py b/packages/meeseeks_tools/src/meeseeks_tools/integration/mcp.py index 8a5a7577..1685ee0e 100644 --- a/packages/meeseeks_tools/src/meeseeks_tools/integration/mcp.py +++ b/packages/meeseeks_tools/src/meeseeks_tools/integration/mcp.py @@ -325,7 +325,7 @@ def run(self, action_step: ActionStep) -> MockSpeaker: if action_step is None: raise ValueError("Action step cannot be None.") MockSpeakerType = get_mock_speaker() - result = asyncio.run(self._invoke_async(action_step.action_argument)) + result = asyncio.run(self._invoke_async(action_step.tool_input)) return MockSpeakerType(content=result) diff --git a/pyproject.toml b/pyproject.toml index 8b115de7..d0e5e081 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "meeseeks-workspace" -version = "2.1.0-alpha" +version = "0.0.7" description = "Workspace package for the Meeseeks monorepo." readme = "README.md" requires-python = ">=3.10,<4.0" @@ -9,18 +9,18 @@ authors = [ ] license = { text = "MIT" } dependencies = [ - "meeseeks-core>=2.1.0-alpha", + "meeseeks-core>=0.0.7", ] [project.scripts] meeseeks = "meeseeks_cli.cli_master:main" [project.optional-dependencies] -cli = ["meeseeks-cli>=2.1.0-alpha"] -api = ["meeseeks-api>=2.1.0-alpha"] -chat = ["meeseeks-chat>=2.1.0-alpha"] -ha = ["meeseeks-ha-conversation>=2.1.0-alpha"] -tools = ["meeseeks-tools>=2.1.0-alpha"] +cli = ["meeseeks-cli>=0.0.7"] +api = ["meeseeks-api>=0.0.7"] +chat = ["meeseeks-chat>=0.0.7"] +ha = ["meeseeks-ha-conversation>=0.0.7"] +tools = ["meeseeks-tools>=0.0.7"] [dependency-groups] dev = [ diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 144b6724..70f3ddac 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -15,7 +15,7 @@ Scope: this file applies to the root `tests/` suite and shared test patterns. ## Pitfalls / gotchas - Over-mocking hides real behavior. Mock only the LLM call boundary and tool execution boundary. -- Schema mismatches must be exercised (string args, dict args, invalid schema, required fields). +- Schema mismatches must be exercised (string tool_input, dict tool_input, invalid schema, required fields). - Replan tests should include failure context (“Last tool failure: …”) in the next prompt. - When a failure triggers replan, disable response synthesis or stub it to avoid network calls. - Missing-tool tests should assert `last_error` and follow the same path as production. @@ -32,7 +32,7 @@ Scope: this file applies to the root `tests/` suite and shared test patterns. ## Cross-project insights (for test design) - Reference implementations emphasize integration tests with mocked model/tool servers. -- Assert outbound request payloads and event ordering rather than only return values. +- Assert outbound request payloads and event ordering rather than only return values (tool_id/operation/tool_input). - Build tests around “event streams” (plan, tool call, tool result, response) to catch orchestration regressions. - Prefer harness-style helpers to simulate model responses without HTTP. - Exercise error paths with structured exceptions to verify logging and replan behavior. diff --git a/tests/test_action_runner.py b/tests/test_action_runner.py index d68b9bed..a22336c7 100644 --- a/tests/test_action_runner.py +++ b/tests/test_action_runner.py @@ -19,9 +19,9 @@ def test_execute_step_raises_when_tool_missing(): hook_manager=default_hook_manager(), ) step = ActionStep( - action_consumer="missing_tool", - action_type="get", - action_argument="ping", + tool_id="missing_tool", + operation="get", + tool_input="ping", ) with pytest.raises(RuntimeError): runner._execute_step(step) @@ -50,9 +50,9 @@ def run(self, _step): hook_manager=default_hook_manager(), ) step = ActionStep( - action_consumer="dummy_tool", - action_type="get", - action_argument="ping", + tool_id="dummy_tool", + operation="get", + tool_input="ping", ) outcome = runner._execute_step(step) assert outcome.content == "" @@ -89,9 +89,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="unsafe_tool", - action_type="set", - action_argument="payload", + tool_id="unsafe_tool", + operation="set", + tool_input="payload", ) ] ) @@ -135,9 +135,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="dummy_tool", - action_type="set", - action_argument="payload", + tool_id="dummy_tool", + operation="set", + tool_input="payload", ) ] ) @@ -179,9 +179,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="dummy_tool", - action_type="set", - action_argument="payload", + tool_id="dummy_tool", + operation="set", + tool_input="payload", ) ] ) @@ -219,9 +219,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="exploding_tool", - action_type="get", - action_argument="payload", + tool_id="exploding_tool", + operation="get", + tool_input="payload", ) ] ) @@ -258,9 +258,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="input_error_tool", - action_type="get", - action_argument="payload", + tool_id="input_error_tool", + operation="get", + tool_input="payload", ) ] ) @@ -300,9 +300,9 @@ def run(self, _step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="mcp_tool", - action_type="get", - action_argument="payload", + tool_id="mcp_tool", + operation="get", + tool_input="payload", ) ] ) @@ -324,9 +324,9 @@ def test_summarize_result_truncates_long_text(): def test_format_step_summary_skips_empty_result(): """Skip summaries when tool output is empty.""" step = ActionStep( - action_consumer="dummy", - action_type="get", - action_argument="payload", + tool_id="dummy", + operation="get", + tool_input="payload", ) step.result = get_mock_speaker()(content="") assert ActionPlanRunner._format_step_summary(step) == "" @@ -336,3 +336,81 @@ def test_summarize_result_none_returns_empty(): """Return empty summaries for None results.""" summary = ActionPlanRunner._summarize_result(None, None) assert summary == "" + + +def test_action_runner_coerces_json_string_payload(): + """Parse JSON string tool_input into a structured payload.""" + step = ActionStep( + tool_id="mcp_json_tool", + operation="get", + tool_input='{"query": "hello"}', + ) + spec = ToolSpec( + tool_id="mcp_json_tool", + name="JSON tool", + description="MCP tool", + factory=lambda: object(), + kind="mcp", + metadata={ + "schema": { + "required": ["query"], + "properties": {"query": {"type": "string"}}, + } + }, + ) + error = ActionPlanRunner._coerce_mcp_tool_input(step, spec) + assert error is None + assert step.tool_input == {"query": "hello"} + + +def test_action_runner_preserves_output_on_reflection(): + """Keep tool output in tool_result payload when reflection requests retry/revise.""" + registry = ToolRegistry() + events = [] + + class DummyTool: + def run(self, _step): + return get_mock_speaker()(content="raw output") + + class DummyReflector: + def reflect(self, *_args, **_kwargs): + class DummyReflection: + status = "retry" + notes = "needs more" + revised_argument = None + + return DummyReflection() + + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="Dummy tool", + factory=lambda: DummyTool(), + ) + ) + runner = ActionPlanRunner( + tool_registry=registry, + permission_policy=PermissionPolicy(), + approval_callback=lambda _step: True, + hook_manager=default_hook_manager(), + event_logger=events.append, + reflector=DummyReflector(), + ) + task_queue = TaskQueue( + action_steps=[ + ActionStep( + tool_id="dummy_tool", + operation="get", + tool_input="payload", + ) + ] + ) + task_queue = runner.run(task_queue) + tool_events = [event for event in events if event.get("type") == "tool_result"] + assert tool_events + payload = tool_events[-1]["payload"] + assert payload.get("success") is False + assert payload.get("error") + assert payload.get("result") == "raw output" + assert task_queue.action_steps[0].result is not None diff --git a/tests/test_aider_edit_blocks.py b/tests/test_aider_edit_blocks.py index 684dbfc3..86ed3fbc 100644 --- a/tests/test_aider_edit_blocks.py +++ b/tests/test_aider_edit_blocks.py @@ -19,14 +19,7 @@ def _block(path: str, search: str, replace: str) -> str: - return ( - f"{path}\n" - "```text\n" - "<<<<<<< SEARCH\n" - f"{search}=======\n" - f"{replace}>>>>>>> REPLACE\n" - "```\n" - ) + return f"{path}\n```text\n<<<<<<< SEARCH\n{search}=======\n{replace}>>>>>>> REPLACE\n```\n" def test_apply_search_replace_block(tmp_path): @@ -95,7 +88,7 @@ def test_search_miss_includes_hint(tmp_path): @pytest.mark.parametrize( - ("action_type", "content", "files", "expected"), + ("operation", "content", "files", "expected"), [ ("set", "no edits here", None, "SEARCH/REPLACE blocks"), ("get", "no edits here", None, "SEARCH/REPLACE blocks"), @@ -104,31 +97,44 @@ def test_search_miss_includes_hint(tmp_path): ("set", _block("hello.txt", "world\n", "there\n"), "hello.txt", "files must be a list"), ], ) -def test_edit_block_tool_rejects_invalid_inputs(tmp_path, action_type, content, files, expected): +def test_edit_block_tool_rejects_invalid_inputs(tmp_path, operation, content, files, expected): """Reject invalid tool inputs with guidance.""" tool = AiderEditBlockTool() argument = {"content": content, "root": str(tmp_path)} if files is not None: argument["files"] = files step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type=action_type, - action_argument=argument, + tool_id="aider_edit_block_tool", + operation=operation, + tool_input=argument, ) with pytest.raises(ToolInputError) as exc: tool.run(step) assert expected in str(exc.value) +def test_edit_block_tool_rejects_invalid_payload_type(tmp_path): + """Reject non-string/non-object payloads.""" + tool = AiderEditBlockTool() + step = ActionStep.construct( + tool_id="aider_edit_block_tool", + operation="set", + tool_input=123, + ) + with pytest.raises(ToolInputError) as exc: + tool.run(step) + assert "Tool input must be a string or object payload" in str(exc.value) + + def test_edit_block_tool_wraps_apply_errors(tmp_path): """Wrap apply errors with format guidance.""" target = tmp_path / "hello.txt" target.write_text("alpha\nbeta\ngamma\ndelta\n", encoding="utf-8") tool = AiderEditBlockTool() step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument={ + tool_id="aider_edit_block_tool", + operation="set", + tool_input={ "content": _block("hello.txt", "alpha\nbeta\ngamaa\ndelta\n", "there\n"), "root": str(tmp_path), }, @@ -146,9 +152,9 @@ def test_edit_block_tool_set_state_returns_diff(tmp_path): target.write_text("hello\nworld\n", encoding="utf-8") tool = AiderEditBlockTool() step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument={ + tool_id="aider_edit_block_tool", + operation="set", + tool_input={ "content": _block("hello.txt", "world\n", "there\n"), "root": str(tmp_path), }, @@ -167,9 +173,9 @@ def test_edit_block_tool_set_state_summary_when_no_diff(tmp_path): target.write_text("hello\nworld\n", encoding="utf-8") tool = AiderEditBlockTool() step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument={ + tool_id="aider_edit_block_tool", + operation="set", + tool_input={ "content": _block("hello.txt", "world\n", "world\n"), "root": str(tmp_path), }, @@ -185,9 +191,9 @@ def test_edit_block_tool_get_state_summary(tmp_path): target.write_text("hello\nworld\n", encoding="utf-8") tool = AiderEditBlockTool() step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="get", - action_argument={ + tool_id="aider_edit_block_tool", + operation="get", + tool_input={ "content": _block("hello.txt", "world\n", "there\n"), "root": str(tmp_path), }, @@ -204,17 +210,14 @@ def test_edit_block_tool_uses_cwd_for_string_argument(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) tool = AiderEditBlockTool() step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument=_block("hello.txt", "world\n", "there\n"), + tool_id="aider_edit_block_tool", + operation="set", + tool_input=_block("hello.txt", "world\n", "there\n"), ) tool.set_state(step) assert target.read_text(encoding="utf-8") == "hello\nthere\n" - - - def test_edit_block_tool_input_error_guidance_when_blank(): """Return guidance when no message is provided.""" message = _format_tool_input_error("") @@ -257,14 +260,7 @@ def test_apply_search_replace_block_missing_leading_whitespace(tmp_path): ) def test_parse_search_replace_blocks_filename_variants(header, expected): """Normalize filename markers before SEARCH blocks.""" - content = ( - f"{header}\n" - "<<<<<<< SEARCH\n" - "old\n" - "=======\n" - "new\n" - ">>>>>>> REPLACE\n" - ) + content = f"{header}\n<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n" edits, shell_blocks = parse_search_replace_blocks(content, valid_fnames=None) assert not shell_blocks assert edits[0].path == expected @@ -272,13 +268,6 @@ def test_parse_search_replace_blocks_filename_variants(header, expected): def test_parse_search_replace_blocks_prefers_valid_fnames(): """Prefer close matches from valid filename list.""" - content = ( - "file.txt\n" - "<<<<<<< SEARCH\n" - "old\n" - "=======\n" - "new\n" - ">>>>>>> REPLACE\n" - ) + content = "file.txt\n<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE\n" edits, _ = parse_search_replace_blocks(content, valid_fnames=["a/file.txt"]) assert edits[0].path == "a/file.txt" diff --git a/tests/test_aider_file_tools.py b/tests/test_aider_file_tools.py index 76a46409..7bb815bd 100644 --- a/tests/test_aider_file_tools.py +++ b/tests/test_aider_file_tools.py @@ -13,9 +13,9 @@ def test_aider_read_file_tool_reads(tmp_path): tool = AiderReadFileTool() step = ActionStep( - action_consumer="aider_read_file_tool", - action_type="get", - action_argument={"path": "hello.txt", "root": str(tmp_path)}, + tool_id="aider_read_file_tool", + operation="get", + tool_input={"path": "hello.txt", "root": str(tmp_path)}, ) result = tool.get_state(step) @@ -33,9 +33,9 @@ def test_aider_list_dir_tool_lists(tmp_path): tool = AiderListDirTool() step = ActionStep( - action_consumer="aider_list_dir_tool", - action_type="get", - action_argument={"path": "a", "root": str(tmp_path)}, + tool_id="aider_list_dir_tool", + operation="get", + tool_input={"path": "a", "root": str(tmp_path)}, ) result = tool.get_state(step) @@ -52,9 +52,9 @@ def test_aider_read_file_blocks_escape(tmp_path): """Reject path traversal attempts.""" tool = AiderReadFileTool() step = ActionStep( - action_consumer="aider_read_file_tool", - action_type="get", - action_argument={"path": "../oops.txt", "root": str(tmp_path)}, + tool_id="aider_read_file_tool", + operation="get", + tool_input={"path": "../oops.txt", "root": str(tmp_path)}, ) result = tool.get_state(step) assert isinstance(result.content, str) @@ -68,9 +68,9 @@ def test_aider_read_file_truncates(tmp_path): tool = AiderReadFileTool() step = ActionStep( - action_consumer="aider_read_file_tool", - action_type="get", - action_argument={"path": "long.txt", "root": str(tmp_path), "max_bytes": "5"}, + tool_id="aider_read_file_tool", + operation="get", + tool_input={"path": "long.txt", "root": str(tmp_path), "max_bytes": "5"}, ) result = tool.get_state(step) @@ -83,15 +83,28 @@ def test_aider_read_file_invalid_argument_type(): """Reject missing path payloads.""" tool = AiderReadFileTool() step = ActionStep( - action_consumer="aider_read_file_tool", - action_type="get", - action_argument={"path": ""}, + tool_id="aider_read_file_tool", + operation="get", + tool_input={"path": ""}, ) result = tool.get_state(step) assert isinstance(result.content, str) assert "path is required" in result.content +def test_aider_read_file_rejects_non_string_payload(): + """Reject invalid tool input types.""" + tool = AiderReadFileTool() + step = ActionStep.construct( + tool_id="aider_read_file_tool", + operation="get", + tool_input=123, + ) + result = tool.get_state(step) + assert isinstance(result.content, str) + assert "Tool input must be a string path" in result.content + + def test_aider_list_dir_limits_entries(tmp_path): """Stop listing when max_entries is reached.""" (tmp_path / "a").mkdir() @@ -100,9 +113,9 @@ def test_aider_list_dir_limits_entries(tmp_path): tool = AiderListDirTool() step = ActionStep( - action_consumer="aider_list_dir_tool", - action_type="get", - action_argument={"path": "a", "root": str(tmp_path), "max_entries": 1}, + tool_id="aider_list_dir_tool", + operation="get", + tool_input={"path": "a", "root": str(tmp_path), "max_entries": 1}, ) result = tool.get_state(step) @@ -116,11 +129,24 @@ def test_aider_list_dir_defaults_to_root(tmp_path): (tmp_path / "root.txt").write_text("data", encoding="utf-8") tool = AiderListDirTool() step = ActionStep( - action_consumer="aider_list_dir_tool", - action_type="get", - action_argument={"path": "", "root": str(tmp_path), "max_entries": 10}, + tool_id="aider_list_dir_tool", + operation="get", + tool_input={"path": "", "root": str(tmp_path), "max_entries": 10}, ) result = tool.get_state(step) payload = result.content assert isinstance(payload, dict) assert payload.get("kind") == "dir" + + +def test_aider_list_dir_rejects_invalid_payload_type(): + """Reject invalid tool input types.""" + tool = AiderListDirTool() + step = ActionStep.construct( + tool_id="aider_list_dir_tool", + operation="get", + tool_input=123, + ) + result = tool.get_state(step) + assert isinstance(result.content, str) + assert "Tool input must be a string path" in result.content diff --git a/tests/test_aider_shell_tool.py b/tests/test_aider_shell_tool.py index 8d306474..5f9d5026 100644 --- a/tests/test_aider_shell_tool.py +++ b/tests/test_aider_shell_tool.py @@ -19,9 +19,9 @@ def _fake_run_cmd(command, cwd): tool = AiderShellTool() step = ActionStep( - action_consumer="aider_shell_tool", - action_type="set", - action_argument={"command": "echo hello", "root": str(tmp_path)}, + tool_id="aider_shell_tool", + operation="set", + tool_input={"command": "echo hello", "root": str(tmp_path)}, ) result = tool.set_state(step) payload = result.content @@ -43,9 +43,9 @@ def _fake_run_cmd(command, cwd): tool = AiderShellTool() step = ActionStep( - action_consumer="aider_shell_tool", - action_type="set", - action_argument={"command": "pwd", "root": str(tmp_path), "cwd": "subdir"}, + tool_id="aider_shell_tool", + operation="set", + tool_input={"command": "pwd", "root": str(tmp_path), "cwd": "subdir"}, ) result = tool.set_state(step) payload = result.content @@ -57,9 +57,9 @@ def test_shell_tool_blocks_escape(tmp_path): """Reject cwd that escapes the root.""" tool = AiderShellTool() step = ActionStep( - action_consumer="aider_shell_tool", - action_type="set", - action_argument={"command": "pwd", "root": str(tmp_path), "cwd": "../"}, + tool_id="aider_shell_tool", + operation="set", + tool_input={"command": "pwd", "root": str(tmp_path), "cwd": "../"}, ) result = tool.set_state(step) assert isinstance(result.content, str) @@ -70,10 +70,23 @@ def test_shell_tool_requires_command(tmp_path): """Reject missing command input.""" tool = AiderShellTool() step = ActionStep( - action_consumer="aider_shell_tool", - action_type="set", - action_argument={"command": "", "root": str(tmp_path)}, + tool_id="aider_shell_tool", + operation="set", + tool_input={"command": "", "root": str(tmp_path)}, ) result = tool.set_state(step) assert isinstance(result.content, str) assert "command is required" in result.content + + +def test_shell_tool_rejects_invalid_payload_type(): + """Reject invalid tool input types.""" + tool = AiderShellTool() + step = ActionStep.construct( + tool_id="aider_shell_tool", + operation="set", + tool_input=123, + ) + result = tool.set_state(step) + assert isinstance(result.content, str) + assert "Tool input must be a string command" in result.content diff --git a/tests/test_common.py b/tests/test_common.py index 41844c5c..e5a035ca 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -12,3 +12,23 @@ def test_get_logger_uses_config_and_defaults(monkeypatch): logger = common.get_logger() assert common._LOG_CONFIGURED is True assert logger is not None + + +def test_session_log_context_reuses_and_releases_sinks(monkeypatch, tmp_path): + """Reuse session log sinks and clean them up.""" + monkeypatch.setattr(common, "_SESSION_SINKS", {}) + monkeypatch.setattr(common, "_LOG_CONFIGURED", False) + + session_id = "session-logs-1" + common._ensure_session_log_sink(session_id, log_dir=str(tmp_path)) + assert common._SESSION_SINKS[session_id]["count"] == 1 + + common._ensure_session_log_sink(session_id, log_dir=str(tmp_path)) + assert common._SESSION_SINKS[session_id]["count"] == 2 + + common._release_session_log_sink("missing-session") + common._release_session_log_sink(session_id) + assert common._SESSION_SINKS[session_id]["count"] == 1 + + common._release_session_log_sink(session_id) + assert session_id not in common._SESSION_SINKS diff --git a/tests/test_components.py b/tests/test_components.py index aae107cb..f583c0fd 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -48,7 +48,7 @@ def test_format_component_status(): ComponentStatus(name="home_assistant_tool", enabled=True), ] ) - assert status_text == ("- langfuse: disabled (disabled)\n" "- home_assistant_tool: enabled") + assert status_text == ("- langfuse: disabled (disabled)\n- home_assistant_tool: enabled") def test_langfuse_status_requires_keys(monkeypatch): diff --git a/tests/test_context.py b/tests/test_context.py index d6c2727b..5ed220b6 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -11,7 +11,7 @@ def test_event_payload_text_variants(): """Format payloads with and without dict structures.""" assert event_payload_text({"type": "user", "payload": "hello"}) == "hello" text = event_payload_text( - {"type": "tool_result", "payload": {"action_argument": {"a": 1}, "result": "ok"}} + {"type": "tool_result", "payload": {"tool_input": {"a": 1}, "result": "ok"}} ) assert "ok" in text fallback = event_payload_text({"type": "tool_result", "payload": {"foo": "bar"}}) diff --git a/tests/test_core_classes.py b/tests/test_core_classes.py index 44fb377e..243cfe61 100644 --- a/tests/test_core_classes.py +++ b/tests/test_core_classes.py @@ -1,5 +1,6 @@ """Tests for core class behaviors.""" +import json import os import meeseeks_core.classes as classes @@ -12,51 +13,51 @@ def test_action_step_normalization(): """Normalize action step fields and tool identifiers.""" set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="HOME_ASSISTANT_TOOL", - action_type="SET", - action_argument="hello", + tool_id="HOME_ASSISTANT_TOOL", + operation="SET", + tool_input="hello", ) queue = TaskQueue(action_steps=[step]) - assert queue.action_steps[0].action_consumer == "home_assistant_tool" - assert queue.action_steps[0].action_type == "set" + assert queue.action_steps[0].tool_id == "home_assistant_tool" + assert queue.action_steps[0].operation == "set" def test_action_step_accepts_dict_argument(): - """Allow structured action arguments for schema-based tools.""" + """Allow structured tool inputs for schema-based tools.""" set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument={"message": "hello"}, + tool_id="home_assistant_tool", + operation="set", + tool_input={"message": "hello"}, ) queue = TaskQueue(action_steps=[step]) - assert queue.action_steps[0].action_argument == {"message": "hello"} + assert queue.action_steps[0].tool_input == {"message": "hello"} def test_action_step_invalid_entries(): """Normalize invalid tool/action entries to lower case.""" set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="UNKNOWN_TOOL", - action_type="GET", - action_argument="hello", + tool_id="UNKNOWN_TOOL", + operation="GET", + tool_input="hello", ) queue = TaskQueue(action_steps=[step]) - assert queue.action_steps[0].action_consumer == "unknown_tool" - assert queue.action_steps[0].action_type == "get" + assert queue.action_steps[0].tool_id == "unknown_tool" + assert queue.action_steps[0].operation == "get" def test_action_step_validation_logs_for_invalid_entries(): """Trigger validation warnings for invalid action data.""" set_available_tools(["home_assistant_tool"]) step = ActionStep.construct( - action_consumer="bad_tool", - action_type="bad", - action_argument=None, + tool_id="bad_tool", + operation="bad", + tool_input=None, ) queue = TaskQueue(action_steps=[step]) - assert queue.action_steps[0].action_consumer == "bad_tool" - assert queue.action_steps[0].action_type == "bad" + assert queue.action_steps[0].tool_id == "bad_tool" + assert queue.action_steps[0].operation == "bad" def test_save_json(tmp_path, monkeypatch): @@ -81,15 +82,15 @@ def test_create_task_queue_and_examples(): """Create task queues and validate example lookup errors.""" action_data = [ { - "action_consumer": "home_assistant_tool", - "action_type": "get", - "action_argument": "hello", + "tool_id": "home_assistant_tool", + "operation": "get", + "tool_input": "hello", } ] queue = create_task_queue(action_data=action_data, is_example=False) - assert queue.action_steps[0].action_argument == "hello" + assert queue.action_steps[0].tool_input == "hello" examples = classes.get_task_master_examples(0, available_tools=["home_assistant_tool"]) - assert "action_steps" in examples + assert "steps" in examples with pytest.raises(ValueError): classes.get_task_master_examples(99, available_tools=["home_assistant_tool"]) @@ -100,17 +101,25 @@ def test_create_task_queue_requires_data(): create_task_queue(action_data=None) +def test_create_plan_requires_data(): + """Raise when plan data is missing.""" + with pytest.raises(ValueError): + classes.create_plan(step_data=None) + + def test_examples_skip_home_assistant_when_unavailable(): """Ensure examples omit disabled tools.""" examples = classes.get_task_master_examples(0, available_tools=[]) - assert "home_assistant_tool" not in examples + payload = json.loads(examples) + assert payload["steps"] == [] def test_examples_use_available_tools_by_default(): """Use global available tools when not provided.""" set_available_tools(["home_assistant_tool"]) examples = classes.get_task_master_examples(0, available_tools=None) - assert "home_assistant_tool" in examples + payload = json.loads(examples) + assert payload["steps"] def test_abstract_tool_init_and_run(monkeypatch, tmp_path): @@ -136,9 +145,9 @@ def __init__(self): tool = DummyTool() set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="hello", + tool_id="home_assistant_tool", + operation="set", + tool_input="hello", ) result = tool.run(step) assert result.content == "Not implemented yet." @@ -193,15 +202,15 @@ def __init__(self): tool = DummyTool() set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="hello", + tool_id="home_assistant_tool", + operation="get", + tool_input="hello", ) assert tool.run(step).content == "Not implemented yet." step = ActionStep( - action_consumer="home_assistant_tool", - action_type="bad", - action_argument="hello", + tool_id="home_assistant_tool", + operation="bad", + tool_input="hello", ) with pytest.raises(ValueError): tool.run(step) diff --git a/tests/test_notification_share_store.py b/tests/test_notification_share_store.py new file mode 100644 index 00000000..fbcb1959 --- /dev/null +++ b/tests/test_notification_share_store.py @@ -0,0 +1,57 @@ +"""Tests for notification and share stores.""" + +from meeseeks_core.config import set_config_override +from meeseeks_core.notifications import NotificationStore +from meeseeks_core.share_store import ShareStore + + +def test_notification_store_handles_corrupt_json(tmp_path): + """Return empty list when stored JSON is invalid.""" + set_config_override({"runtime": {"session_dir": str(tmp_path)}}) + store = NotificationStore() + with open(store._path, "w", encoding="utf-8") as handle: + handle.write("{invalid}") + assert store.list() == [] + + +def test_notification_store_dismiss_and_clear(tmp_path): + """Dismiss and clear notifications with edge cases.""" + store = NotificationStore(root_dir=str(tmp_path)) + first = store.add(title="one", message="first") + second = store.add(title="two", message="second") + assert store.dismiss([]) == 0 + assert store.dismiss([first["id"], second["id"]]) == 2 + removed = store.clear(dismissed_only=False) + assert removed == 2 + assert store.list(include_dismissed=True) == [] + + +def test_notification_store_ignores_non_list_payload(tmp_path): + """Return empty list when stored JSON is not a list.""" + set_config_override({"runtime": {"session_dir": str(tmp_path)}}) + store = NotificationStore() + with open(store._path, "w", encoding="utf-8") as handle: + handle.write('{"foo": "bar"}') + assert store.list() == [] + + +def test_share_store_handles_invalid_json_and_tokens(tmp_path): + """Handle corrupt JSON and missing token cases.""" + set_config_override({"runtime": {"session_dir": str(tmp_path)}}) + store = ShareStore() + with open(store._path, "w", encoding="utf-8") as handle: + handle.write("{invalid}") + assert store.resolve("") is None + assert store.resolve("token") is None + assert store.revoke("") is False + assert store.revoke("missing") is False + with open(store._path, "w", encoding="utf-8") as handle: + handle.write('["token"]') + assert store.resolve("token") is None + + +def test_share_store_revokes_existing_token(tmp_path): + """Revoke a valid share token.""" + store = ShareStore(root_dir=str(tmp_path)) + record = store.create("session-1") + assert store.revoke(record["token"]) is True diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py index 0b0a99e4..ea85227d 100644 --- a/tests/test_orchestration.py +++ b/tests/test_orchestration.py @@ -1,11 +1,17 @@ """Tests for orchestration workflows.""" import json +import types from langchain_core.runnables import RunnableLambda # noqa: E402 from meeseeks_core import planning, task_master # noqa: E402 -from meeseeks_core.action_runner import ActionPlanRunner # noqa: E402 -from meeseeks_core.classes import ActionStep, TaskQueue, set_available_tools # noqa: E402 +from meeseeks_core.classes import ( # noqa: E402 + ActionStep, + Plan, + PlanStep, + TaskQueue, + set_available_tools, +) from meeseeks_core.common import get_mock_speaker # noqa: E402 from meeseeks_core.config import set_config_override # noqa: E402 from meeseeks_core.context import ContextBuilder # noqa: E402 @@ -16,7 +22,12 @@ PermissionPolicy, PermissionRule, ) -from meeseeks_core.planning import Planner, ResponseSynthesizer # noqa: E402 +from meeseeks_core.planning import ( # noqa: E402 + Planner, + PlanUpdater, + ResponseSynthesizer, + StepExecutor, +) from meeseeks_core.reflection import StepReflector # noqa: E402 from meeseeks_core.session_store import SessionStore # noqa: E402 from meeseeks_core.tool_registry import ToolRegistry, ToolSpec, load_registry # noqa: E402 @@ -36,22 +47,13 @@ def bump(self): def _edit_block(path: str, search: str, replace: str) -> str: - return ( - f"{path}\n" - "```text\n" - "<<<<<<< SEARCH\n" - f"{search}=======\n" - f"{replace}>>>>>>> REPLACE\n" - "```\n" - ) + return f"{path}\n```text\n<<<<<<< SEARCH\n{search}=======\n{replace}>>>>>>> REPLACE\n```\n" def _types_in_order(events, expected): indices = [] for value in expected: - indices.append( - next(i for i, event in enumerate(events) if event["type"] == value) - ) + indices.append(next(i for i, event in enumerate(events) if event["type"] == value)) assert indices == sorted(indices) @@ -59,25 +61,54 @@ def make_task_queue(message: str) -> TaskQueue: """Build a minimal task queue with a single action step.""" set_available_tools(["home_assistant_tool"]) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument=message, + tool_id="home_assistant_tool", + operation="get", + tool_input=message, ) return TaskQueue(action_steps=[step]) +def make_plan(title: str, description: str | None = None) -> Plan: + """Build a minimal plan with a single step.""" + return Plan( + steps=[ + PlanStep( + title=title, + description=description or title, + ) + ] + ) + + def test_orchestrate_session_completes(monkeypatch, tmp_path): """Return a completed task queue when execution succeeds.""" generate_calls = Counter() run_calls = Counter() session_store = SessionStore(root_dir=str(tmp_path)) session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, + ) + ) def fake_generate(*_args, **_kwargs): generate_calls.bump() - return make_task_queue("say hi") + return make_plan("Say hi") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="say hi", + response=None, + ) - def fake_run(_self, task_queue): + def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): run_calls.bump() MockSpeaker = get_mock_speaker() task_queue.action_steps[0].result = MockSpeaker(content="done") @@ -85,7 +116,8 @@ def fake_run(_self, task_queue): return task_queue monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "done") task_queue = task_master.orchestrate_session( @@ -93,6 +125,7 @@ def fake_run(_self, task_queue): max_iters=3, session_id=session_id, session_store=session_store, + tool_registry=registry, ) assert task_queue.task_result == "done" @@ -105,7 +138,7 @@ def test_orchestrator_creates_session_when_missing(monkeypatch, tmp_path): session_store = SessionStore(root_dir=str(tmp_path)) registry = ToolRegistry() - monkeypatch.setattr(Planner, "generate", lambda *_a, **_k: TaskQueue(action_steps=[])) + monkeypatch.setattr(Planner, "generate", lambda *_a, **_k: Plan(steps=[])) monkeypatch.setattr( Orchestrator, "_run_action_plan", lambda *_a, **_k: TaskQueue(action_steps=[]) ) @@ -134,7 +167,7 @@ def _fake_model(messages): message.content for message in messages if getattr(message, "content", None) ) assert "Available tools:\n- home_assistant_tool" not in combined - payload = {"action_steps": []} + payload = {"steps": []} return json.dumps(payload) monkeypatch.setattr( @@ -143,15 +176,15 @@ def _fake_model(messages): lambda **_kwargs: RunnableLambda(_fake_model), ) - task_queue = task_master.generate_action_plan( + plan = task_master.generate_action_plan( "hi", tool_registry=registry, ) - assert task_queue.action_steps == [] + assert plan.steps == [] -def test_generate_action_plan_plan_mode_filters_tools(monkeypatch): - """Expose only plan-safe tools and avoid extra guidance in plan mode.""" +def test_generate_action_plan_plan_mode_includes_all_tools(monkeypatch): + """Expose all tools and avoid extra guidance in plan mode.""" registry = ToolRegistry() registry.register( ToolSpec( @@ -178,9 +211,9 @@ def _fake_model(messages): message.content for message in messages if getattr(message, "content", None) ) assert "plan_tool" in combined - assert "mut_tool" not in combined + assert "mut_tool" in combined assert "Tool guidance:" not in combined - return json.dumps({"action_steps": []}) + return json.dumps({"steps": []}) monkeypatch.setattr( planning, @@ -188,55 +221,88 @@ def _fake_model(messages): lambda **_kwargs: RunnableLambda(_fake_model), ) - task_queue = task_master.generate_action_plan( + plan = task_master.generate_action_plan( "make a plan", tool_registry=registry, mode="plan", ) - assert task_queue.action_steps == [] + assert plan.steps == [] -def test_orchestrate_session_replans_on_failure(monkeypatch, tmp_path): - """Replan when an action plan fails once.""" +def test_orchestrate_session_marks_incomplete_on_failure(monkeypatch, tmp_path): + """Mark completion as incomplete when a tool step fails.""" generate_calls = Counter() run_calls = Counter() - captured = {} session_store = SessionStore(root_dir=str(tmp_path)) session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, + ) + ) - def fake_generate(_self, user_query, *_args, **_kwargs): + def fake_generate(*_args, **_kwargs): generate_calls.bump() - if generate_calls.count == 2: - captured["query"] = user_query - return make_task_queue("say hi") + return Plan( + steps=[ + PlanStep(title="Run dummy tool", description="Execute the tool."), + PlanStep(title="Respond", description="Summarize the outcome."), + ] + ) + + def fake_decide(*_args, **_kwargs): + step = _args[2] if len(_args) > 2 else None + if isinstance(step, PlanStep) and step.title == "Respond": + return types.SimpleNamespace( + decision="respond", + tool_id=None, + args=None, + response="Failed to complete the tool step.", + ) + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="payload", + response=None, + ) - def fake_run(_self, task_queue): + def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): run_calls.bump() - if run_calls.count == 1: - task_queue.action_steps[0].result = None - task_queue.task_result = "failed" - task_queue.last_error = "home_assistant_tool (get) failed: boom" - else: - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="ok") - task_queue.task_result = "ok" + task_queue.action_steps[0].result = None + task_queue.task_result = "ERROR: boom" + task_queue.last_error = "dummy_tool (get) failed: boom" return task_queue + captured = {} + + def fake_update(*_args, **kwargs): + captured["last_result"] = kwargs.get("last_result") + return kwargs.get("remaining_steps", []) + monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "ok") + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) + monkeypatch.setattr(PlanUpdater, "update", fake_update) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) - task_queue = task_master.orchestrate_session( + task_queue, state = task_master.orchestrate_session( "hello", - max_iters=2, + max_iters=1, session_id=session_id, session_store=session_store, + tool_registry=registry, + return_state=True, ) - assert task_queue.task_result == "ok" - assert generate_calls.count == 2 - assert run_calls.count == 2 - assert "Last tool failure:" in captured["query"] + assert task_queue.task_result == "Failed to complete the tool step." + assert generate_calls.count == 1 + assert run_calls.count == 1 + assert state.done_reason == "completed" + assert "ERROR: boom" in (captured.get("last_result") or "") def test_orchestrate_session_edit_block_success_records_events(monkeypatch, tmp_path): @@ -257,11 +323,14 @@ def test_orchestrate_session_edit_block_success_records_events(monkeypatch, tmp_ ) ) - def fake_generate(_self, *_args, **_kwargs): - step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument={ + def fake_generate(*_args, **_kwargs): + return make_plan("Apply edit block") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="aider_edit_block_tool", + args={ "content": _edit_block( "hello.txt", "alpha\n...\ngamma\n", @@ -269,10 +338,11 @@ def fake_generate(_self, *_args, **_kwargs): ), "root": str(tmp_path), }, + response=None, ) - return TaskQueue(action_steps=[step]) monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "done") task_queue = task_master.orchestrate_session( @@ -290,18 +360,16 @@ def fake_generate(_self, *_args, **_kwargs): events = session_store.load_transcript(session_id) _types_in_order(events, ["user", "action_plan", "tool_result", "completion"]) tool_event = next(event for event in events if event["type"] == "tool_result") - assert tool_event["payload"]["action_consumer"] == "aider_edit_block_tool" + assert tool_event["payload"]["tool_id"] == "aider_edit_block_tool" assert tool_event["payload"]["success"] is True assert "kind': 'diff'" in tool_event["payload"]["result"] -def test_orchestrate_session_edit_block_input_error_replans(monkeypatch, tmp_path): - """Replan on edit-block input errors without disabling the tool.""" +def test_orchestrate_session_edit_block_input_error_marks_incomplete(monkeypatch, tmp_path): + """Mark completion incomplete on edit-block input errors without disabling the tool.""" monkeypatch.setattr("meeseeks_core.session_store._utc_now", lambda: "2024-01-01T00:00:00+00:00") session_store = SessionStore(root_dir=str(tmp_path)) session_id = "sess-edit-error" - captured: dict[str, str] = {} - generate_calls = Counter() registry = ToolRegistry() registry.register( @@ -313,32 +381,33 @@ def test_orchestrate_session_edit_block_input_error_replans(monkeypatch, tmp_pat ) ) - def fake_generate(_self, user_query, *_args, **_kwargs): - generate_calls.bump() - if generate_calls.count == 2: - captured["query"] = user_query - return TaskQueue(action_steps=[]) - step = ActionStep( - action_consumer="aider_edit_block_tool", - action_type="set", - action_argument={"content": "no edits here", "root": str(tmp_path)}, + def fake_generate(*_args, **_kwargs): + return make_plan("Apply invalid edit block") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="aider_edit_block_tool", + args={"content": "no edits here", "root": str(tmp_path)}, + response=None, ) - return TaskQueue(action_steps=[step]) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "done") + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) - task_queue = task_master.orchestrate_session( + task_queue, state = task_master.orchestrate_session( "apply edit", max_iters=2, session_id=session_id, session_store=session_store, tool_registry=registry, approval_callback=lambda *_a, **_k: True, + return_state=True, ) - assert task_queue.task_result == "done" - assert "Last tool failure:" in captured["query"] + assert state.done_reason == "incomplete" + assert task_queue.last_error is not None spec = registry.get_spec("aider_edit_block_tool") assert spec is not None assert spec.enabled is True @@ -354,16 +423,11 @@ def test_orchestrate_session_plan_mode_no_replan(monkeypatch, tmp_path): session_store = SessionStore(root_dir=str(tmp_path)) session_id = session_store.create_session() - def fake_generate(_self, *_args, **_kwargs): + def fake_generate(*_args, **_kwargs): generate_calls.bump() - return make_task_queue("plan it") - - def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): - task_queue.last_error = "tool not allowed in plan mode" - return task_queue + return make_plan("Plan it") monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_queue, state = task_master.orchestrate_session( @@ -377,175 +441,410 @@ def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): assert generate_calls.count == 1 assert state.done is True - assert state.done_reason in {"blocked", "incomplete"} + assert state.done_reason == "planned" + events = session_store.load_transcript(session_id) + completion = next(event for event in events if event["type"] == "completion") + assert completion["payload"].get("done_reason") == "planned" -def test_orchestrate_session_schema_replan_and_context(monkeypatch, tmp_path): - """Exercise schema rendering, event formatting, and replan failures together.""" +def test_orchestrate_session_cancels_before_step(monkeypatch, tmp_path): + """Cancel before running the first step.""" session_store = SessionStore(root_dir=str(tmp_path)) session_id = session_store.create_session() - captured: dict[str, str] = {} - registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, + ) + ) + + def fake_generate(*_args, **_kwargs): + return make_plan("Do work") + + def fake_decide(*_args, **_kwargs): + raise AssertionError("step executor should not run on cancel") + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_queue, state = task_master.orchestrate_session( + "cancel", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + should_cancel=lambda: True, + return_state=True, + ) + + assert task_queue is not None + assert state.done_reason == "canceled" + events = session_store.load_transcript(session_id) + completion = next(event for event in events if event["type"] == "completion") + assert completion["payload"]["done_reason"] == "canceled" + + +def test_orchestrate_session_empty_response_marks_incomplete(monkeypatch, tmp_path): + """Handle respond decisions that omit a response.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + + def fake_generate(*_args, **_kwargs): + return make_plan("Respond") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace(decision="respond", tool_id=None, args=None, response=None) + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_queue, state = task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + return_state=True, + ) + + assert state.done_reason == "incomplete" + assert "empty response" in (task_queue.task_result or "") - class DummyTool: - def run(self, _step): - return get_mock_speaker()(content="ok") +def test_orchestrate_session_invalid_tool_id(monkeypatch, tmp_path): + """Mark incomplete when step executor returns a disallowed tool.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() registry.register( ToolSpec( - tool_id="mcp_bad_schema", - name="Bad schema tool", - description="MCP tool with strict schema", - factory=lambda: DummyTool(), - kind="mcp", - metadata={ - "schema": { - "required": ["query"], - "properties": "bad", - } - }, + tool_id="allowed_tool", + name="Allowed Tool", + description="Allowed tool", + factory=lambda: None, ) ) + + def fake_generate(*_args, **_kwargs): + return make_plan("Run tool") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="missing_tool", + args="payload", + response=None, + ) + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_queue, state = task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + return_state=True, + ) + + assert state.done_reason == "incomplete" + assert "not allowed" in (task_queue.task_result or "") + + +def test_orchestrate_session_coerces_tool_args(monkeypatch, tmp_path): + """Normalize missing and non-string args before tool execution.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() registry.register( ToolSpec( - tool_id="mcp_prop_bad", - name="Prop bad tool", - description="Tool with mixed schema properties", - factory=lambda: DummyTool(), - kind="mcp", - metadata={ - "schema": { - "properties": { - "other": "bad", - "query": {"type": "string", "description": "Search query"}, - } - } - }, + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, ) ) + + def fake_generate(*_args, **_kwargs): + return Plan( + steps=[ + PlanStep(title="Step 1", description="First"), + PlanStep(title="Step 2", description="Second"), + ] + ) + + decisions = iter( + [ + types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args=None, + response=None, + ), + types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args=["a", "b"], + response=None, + ), + ] + ) + + def fake_decide(*_args, **_kwargs): + return next(decisions) + + captured = [] + + def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): + captured.append(task_queue.action_steps[0].tool_input) + task_queue.task_result = "ok" + return task_queue + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) + + def fake_update(*_args, **kwargs): + return kwargs.get("remaining_steps", []) + + monkeypatch.setattr(PlanUpdater, "update", fake_update) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_master.orchestrate_session( + "hello", + max_iters=2, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + assert captured == ["", "['a', 'b']"] + + +def test_orchestrate_session_invalid_decision(monkeypatch, tmp_path): + """Surface invalid step decisions as errors.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + + def fake_generate(*_args, **_kwargs): + return make_plan("Do it") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace(decision="unknown", tool_id=None, args=None, response=None) + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_queue, state = task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + return_state=True, + ) + + assert state.done_reason == "incomplete" + assert "Invalid step decision" in (task_queue.task_result or "") + + +def test_orchestrate_session_cancels_after_step(monkeypatch, tmp_path): + """Cancel after completing a step.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() registry.register( ToolSpec( - tool_id="mcp_field_non_str", - name="Non-str field tool", - description="Schema with non-string field name", - factory=lambda: DummyTool(), - kind="mcp", - metadata={"schema": {"properties": {1: {"type": "string"}}}}, + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, + ) + ) + + def fake_generate(*_args, **_kwargs): + return make_plan("Run tool") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="payload", + response=None, ) + + def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): + task_queue.task_result = "ok" + return task_queue + + cancel_calls = {"count": 0} + + def should_cancel(): + cancel_calls["count"] += 1 + return cancel_calls["count"] > 1 + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) + + task_queue, state = task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + should_cancel=should_cancel, + return_state=True, ) + + assert task_queue.task_result == "ok" + assert state.done_reason == "canceled" + + +def test_orchestrate_session_exception_before_task_queue(monkeypatch, tmp_path): + """Create a fallback task queue when orchestration fails early.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + + def fake_generate(*_args, **_kwargs): + raise RuntimeError("planner boom") + + monkeypatch.setattr(Planner, "generate", fake_generate) + + task_queue = task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + ) + + assert task_queue.last_error == "planner boom" + events = session_store.load_transcript(session_id) + completion = next(event for event in events if event["type"] == "completion") + assert completion["payload"]["done_reason"] == "error" + + +def test_expand_tool_ids_returns_empty_set(): + """Keep empty tool selections unchanged.""" + assert Orchestrator._expand_tool_ids(set(), []) == set() + + +def test_orchestrate_session_emits_completion_on_exception(monkeypatch, tmp_path): + """Always emit completion when orchestration raises.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() registry.register( ToolSpec( - tool_id="mcp_schema_bad", - name="Schema bad tool", - description="Schema is not a dict", - factory=lambda: DummyTool(), - kind="mcp", - metadata={"schema": "oops"}, + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, ) ) + + def fake_generate(*_args, **_kwargs): + return make_plan("Run dummy tool") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="payload", + response=None, + ) + + def fake_run(_self, *_args, **_kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) + + task_queue = task_master.orchestrate_session( + "trigger error", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + assert task_queue.last_error == "boom" + events = session_store.load_transcript(session_id) + completion = next(event for event in events if event["type"] == "completion") + assert completion["payload"]["done_reason"] == "error" + assert completion["payload"]["error"] == "boom" + + +def test_orchestrate_session_mcp_missing_required_marks_incomplete(monkeypatch, tmp_path): + """Mark completion incomplete when MCP schema requirements are violated.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + + registry = ToolRegistry() + + class DummyTool: + def run(self, _step): + return get_mock_speaker()(content="ok") + registry.register( ToolSpec( - tool_id="mcp_no_preferred", - name="No preferred fields", - description="Schema without preferred fields", + tool_id="mcp_bad_schema", + name="Bad schema tool", + description="MCP tool with strict schema", factory=lambda: DummyTool(), kind="mcp", metadata={ "schema": { - "properties": {"alpha": {"type": "string"}, "beta": {"type": "string"}}, + "required": ["query"], + "properties": {"query": {"type": "string"}}, } }, ) ) - session_store.append_event( - session_id, - {"type": "tool_result", "payload": {"action_argument": {"foo": "bar"}}}, - ) - session_store.append_event( - session_id, - {"type": "tool_result", "payload": {"action_argument": "plain"}}, - ) - - call_count = Counter() + def fake_generate(*_args, **_kwargs): + return make_plan("Call MCP tool with bad args") - def fake_model(messages): - if hasattr(messages, "to_messages"): - messages = messages.to_messages() - call_count.bump() - system = "\n".join(msg.content for msg in messages if getattr(msg, "content", None)) - if call_count.count == 1: - captured["system"] = system - else: - captured["query"] = messages[-1].content - if call_count.count == 1: - payload = { - "action_steps": [ - { - "action_consumer": "mcp_bad_schema", - "action_type": "get", - "action_argument": {"foo": "bar", "baz": "qux"}, - }, - { - "action_consumer": "mcp_prop_bad", - "action_type": "get", - "action_argument": '{"query": "ok"}', - }, - { - "action_consumer": "mcp_prop_bad", - "action_type": "get", - "action_argument": "{bad}", - }, - { - "action_consumer": "mcp_schema_bad", - "action_type": "get", - "action_argument": "ok", - }, - { - "action_consumer": "mcp_bad_schema", - "action_type": "get", - "action_argument": {"foo": "bar"}, - }, - { - "action_consumer": "mcp_no_preferred", - "action_type": "get", - "action_argument": "plain", - }, - ] - } - else: - payload = {"action_steps": []} - return json.dumps(payload) + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="mcp_bad_schema", + args={"foo": "bar", "baz": "qux"}, + response=None, + ) - monkeypatch.setattr( - planning, - "build_chat_model", - lambda **_kwargs: RunnableLambda(fake_model), - ) + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) set_config_override({"context": {"selection_enabled": False}}) policy = PermissionPolicy( rules=[], - default_by_action={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ALLOW}, + default_by_operation={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ALLOW}, default_decision=PermissionDecision.ALLOW, ) - task_master.orchestrate_session( + task_queue, state = task_master.orchestrate_session( "hello", - max_iters=2, + max_iters=1, session_id=session_id, session_store=session_store, tool_registry=registry, permission_policy=policy, approval_callback=lambda *_args: True, + return_state=True, ) - assert '"foo": "bar"' in captured["system"] - assert "query: string - Search query" in captured["system"] - assert "Last tool failure:" in captured["query"] - assert "Expected JSON object with fields" in captured["query"] + assert state.done_reason == "incomplete" + assert task_queue.last_error is not None def test_run_action_plan_records_last_error(): @@ -566,9 +865,9 @@ def run(self, step): ) ) step = ActionStep( - action_consumer="boom_tool", - action_type="get", - action_argument="go", + tool_id="boom_tool", + operation="get", + tool_input="go", ) queue = TaskQueue(action_steps=[step]) task_master.run_action_plan(queue, tool_registry=registry) @@ -580,9 +879,9 @@ def test_run_action_plan_missing_tool_records_last_error(): """Capture failures when a tool is missing from the registry.""" registry = ToolRegistry() step = ActionStep( - action_consumer="missing_tool", - action_type="get", - action_argument="payload", + tool_id="missing_tool", + operation="get", + tool_input="payload", ) queue = TaskQueue(action_steps=[step]) task_master.run_action_plan(queue, tool_registry=registry) @@ -596,7 +895,7 @@ def test_run_action_plan_coerces_mcp_string_payload(): class DummyTool: def run(self, step): - captured.append(step.action_argument) + captured.append(step.tool_input) return get_mock_speaker()(content="ok") registry = ToolRegistry() @@ -647,26 +946,26 @@ def run(self, step): ) steps = [ ActionStep( - action_consumer="mcp_array_tool", - action_type="get", - action_argument={"foo": "value"}, + tool_id="mcp_array_tool", + operation="get", + tool_input={"foo": "value"}, ), ActionStep( - action_consumer="mcp_string_tool", - action_type="get", - action_argument={"foo": ["value"]}, + tool_id="mcp_string_tool", + operation="get", + tool_input={"foo": ["value"]}, ), ActionStep.construct( - action_consumer="mcp_bad_tool", - action_type="get", - action_argument=["bad"], + tool_id="mcp_bad_tool", + operation="get", + tool_input=["bad"], ), ] queue = TaskQueue(action_steps=steps) task_master.run_action_plan(queue, tool_registry=registry) assert captured == [{"query": ["value"]}, {"query": "value"}] assert queue.last_error is not None - assert "Unsupported action_argument type" in queue.last_error + assert "Unsupported tool_input type" in queue.last_error def test_orchestrate_session_passes_summary(monkeypatch, tmp_path): @@ -679,17 +978,10 @@ def test_orchestrate_session_passes_summary(monkeypatch, tmp_path): def fake_generate(_self, _query, *_args, **kwargs): context = kwargs.get("context") captured["summary"] = context.summary if context else None - return make_task_queue("say hi") - - def fake_run(_self, task_queue): - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="ok") - task_queue.task_result = "ok" - return task_queue + return Plan(steps=[]) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "ok") + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( "hello", @@ -706,14 +998,16 @@ def test_response_synthesis_helpers(monkeypatch): queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="tool", - action_type="get", - action_argument="x", + tool_id="tool", + operation="get", + tool_input="x", result=None, ) ] ) assert Orchestrator._collect_tool_outputs(queue) == [] + queue.last_error = "tool failed" + assert Orchestrator._collect_tool_outputs(queue) == ["ERROR: tool failed"] assert Orchestrator._should_synthesize_response(TaskQueue(action_steps=[])) is True def _fake_model(_inputs): @@ -733,22 +1027,12 @@ def _fake_model(_inputs): assert result == "synthesized" -def test_serialize_action_step_includes_optional_fields(): - """Include optional metadata fields in serialized action payloads.""" - step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="turn on", - title="Turn on lights", - objective="Illuminate the room", - execution_checklist=["Use HA", "Target lights"], - expected_output="Lights on", - ) - payload = Orchestrator._serialize_action_step(step) +def test_serialize_plan_step_includes_title_description(): + """Serialize plan steps into action plan payloads.""" + step = PlanStep(title="Turn on lights", description="Use HA to turn on the lights.") + payload = Orchestrator._serialize_plan_step(step) assert payload["title"] == "Turn on lights" - assert payload["objective"] == "Illuminate the room" - assert payload["execution_checklist"] == ["Use HA", "Target lights"] - assert payload["expected_output"] == "Lights on" + assert payload["description"] == "Use HA to turn on the lights." def test_orchestrate_session_updates_summary_on_memory_keyword(monkeypatch, tmp_path): @@ -757,17 +1041,10 @@ def test_orchestrate_session_updates_summary_on_memory_keyword(monkeypatch, tmp_ session_id = session_store.create_session() def fake_generate(*_args, **_kwargs): - return make_task_queue("ok") - - def fake_run(_self, task_queue): - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="ok") - task_queue.task_result = "ok" - return task_queue + return Plan(steps=[]) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "ok") + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( "Remember these numbers 12345", @@ -793,17 +1070,10 @@ def test_orchestrate_session_passes_recent_events(monkeypatch, tmp_path): def fake_generate(_self, _query, *_args, **kwargs): context = kwargs.get("context") captured["recent_events"] = context.recent_events if context else None - return make_task_queue("ok") - - def fake_run(_self, task_queue): - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="ok") - task_queue.task_result = "ok" - return task_queue + return Plan(steps=[]) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "ok") + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( "hello", @@ -822,7 +1092,7 @@ def test_orchestrate_session_records_mcp_tool_result(monkeypatch, tmp_path): class FakeMCPTool: def run(self, step): - return get_mock_speaker()(content=f"fake:{step.action_argument}") + return get_mock_speaker()(content=f"fake:{step.tool_input}") registry = ToolRegistry() registry.register( @@ -842,14 +1112,18 @@ def run(self, step): ) def fake_generate(*_args, **_kwargs): - step = ActionStep( - action_consumer="mcp_fake_search", - action_type="get", - action_argument="Who is Krishnakanth?", + return make_plan("Search for Krishnakanth") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="mcp_fake_search", + args="Who is Krishnakanth?", + response=None, ) - return TaskQueue(action_steps=[step]) monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( @@ -864,8 +1138,8 @@ def fake_generate(*_args, **_kwargs): tool_events = [event for event in events if event.get("type") == "tool_result"] assert tool_events payload = tool_events[-1]["payload"] - assert payload["action_consumer"] == "mcp_fake_search" - assert payload["action_argument"] == {"query": "Who is Krishnakanth?"} + assert payload["tool_id"] == "mcp_fake_search" + assert payload["tool_input"] == {"query": "Who is Krishnakanth?"} assert payload["result"] == "fake:{'query': 'Who is Krishnakanth?'}" @@ -896,14 +1170,18 @@ def run(self, _step): ) def fake_generate(*_args, **_kwargs): - step = ActionStep( - action_consumer="mcp_dummy", - action_type="get", - action_argument="hello", + return make_plan("Call dummy tool") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="mcp_dummy", + args="hello", + response=None, ) - return TaskQueue(action_steps=[step]) monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "final reply") task_queue = task_master.orchestrate_session( @@ -943,19 +1221,12 @@ def fake_select(_self, events, user_query, model_name): def fake_generate(_self, _query, *_args, **kwargs): context = kwargs.get("context") captured["selected_events"] = context.selected_events if context else None - return make_task_queue("ok") - - def fake_run(_self, task_queue): - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="ok") - task_queue.task_result = "ok" - return task_queue + return Plan(steps=[]) set_config_override({"context": {"selection_threshold": 0.0, "recent_event_limit": 1}}) monkeypatch.setattr(ContextBuilder, "_select_context_events", fake_select) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "ok") + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( "hello", @@ -973,19 +1244,39 @@ def test_orchestrate_session_max_iters(monkeypatch, tmp_path): run_calls = Counter() session_store = SessionStore(root_dir=str(tmp_path)) session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy Tool", + description="Test tool", + factory=lambda: None, + ) + ) def fake_generate(*_args, **_kwargs): generate_calls.bump() - return make_task_queue("say hi") + return make_plan("Run dummy tool") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="payload", + response=None, + ) - def fake_run(_self, task_queue): + def fake_run(_self, _session_id, task_queue, *_args, **_kwargs): run_calls.bump() task_queue.action_steps[0].result = None task_queue.task_result = "failed" + task_queue.last_error = "dummy_tool (get) failed: boom" return task_queue monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(Orchestrator, "_run_action_plan", fake_run) + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_queue, state = task_master.orchestrate_session( "hello", @@ -993,6 +1284,7 @@ def fake_run(_self, task_queue): return_state=True, session_id=session_id, session_store=session_store, + tool_registry=registry, ) assert task_queue.task_result == "failed" @@ -1002,10 +1294,276 @@ def fake_run(_self, task_queue): assert run_calls.count == 1 -def test_orchestrator_max_iters_zero_marks_limit(tmp_path): +def test_orchestrate_session_direct_response_short_circuits(monkeypatch, tmp_path): + """Stop execution and skip plan updates when a step responds directly.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + plan = Plan( + steps=[ + PlanStep(title="Step 1", description="Ask for context"), + PlanStep(title="Step 2", description="Use tools"), + ] + ) + called = {"update": False, "synth": False} + + def fake_decide(_self, *_args, **_kwargs): + return planning.StepDecision(decision="respond", response="Need more context.") + + def fake_update(*_args, **_kwargs): + called["update"] = True + return [] + + def fake_synthesize(*_args, **_kwargs): + called["synth"] = True + return "synthesized" + + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr(PlanUpdater, "update", fake_update) + monkeypatch.setattr(ResponseSynthesizer, "synthesize", fake_synthesize) + + task_queue, state = task_master.orchestrate_session( + "hello", + max_iters=3, + return_state=True, + session_id=session_id, + session_store=session_store, + tool_registry=ToolRegistry(), + initial_plan=plan, + ) + + assert state.done is True + assert task_queue.task_result == "Need more context." + assert called["update"] is False + assert called["synth"] is False + + +def test_orchestrate_session_keeps_tools_when_selector_false(monkeypatch, tmp_path): + """Keep tool visibility when the selector says no tools are required.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="Dummy tool", + factory=lambda: None, + ) + ) + plan = Plan(steps=[PlanStep(title="Step 1", description="Fetch info")]) + captured = {} + + def fake_generate(*_args, **_kwargs): + return plan + + def fake_select(*_args, **_kwargs): + return planning.ToolSelection(tool_required=False, tool_ids=[], rationale="none") + + def fake_decide(_self, *_args, **kwargs): + captured["allowed"] = kwargs.get("allowed_tools") + return planning.StepDecision(decision="respond", response="ok") + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(planning.ToolSelector, "select", fake_select) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + + task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + assert captured.get("allowed") + + +def test_orchestrate_session_expands_web_read(monkeypatch, tmp_path): + """Include web_url_read when web_search is selected.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="mcp_utils_internet_search_searxng_web_search", + name="Web Search", + description="Search the web.", + factory=lambda: None, + ) + ) + registry.register( + ToolSpec( + tool_id="mcp_utils_internet_search_web_url_read", + name="Web Read", + description="Read a web URL.", + factory=lambda: None, + ) + ) + captured = {} + + def fake_generate(*_args, **_kwargs): + return Plan(steps=[PlanStep(title="Search", description="Find sources")]) + + def fake_select(*_args, **_kwargs): + return planning.ToolSelection( + tool_required=True, + tool_ids=["mcp_utils_internet_search_searxng_web_search"], + rationale="search first", + ) + + def fake_decide(_self, *_args, **kwargs): + captured["allowed"] = kwargs.get("allowed_tools") + return planning.StepDecision(decision="respond", response="ok") + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(planning.ToolSelector, "select", fake_select) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + + task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + assert any( + spec.tool_id == "mcp_utils_internet_search_web_url_read" + for spec in captured.get("allowed", []) + ) + + +def test_orchestrate_session_adds_web_tools_for_verify_steps(monkeypatch, tmp_path): + """Add web search/read tools when plan requires open/verify steps.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="Dummy tool", + factory=lambda: None, + ) + ) + registry.register( + ToolSpec( + tool_id="mcp_utils_internet_search_web_url_read", + name="Web Read", + description="Read a web URL.", + factory=lambda: None, + ) + ) + registry.register( + ToolSpec( + tool_id="mcp_utils_internet_search_searxng_web_search", + name="Web Search", + description="Search the web.", + factory=lambda: None, + ) + ) + captured = {} + + def fake_generate(*_args, **_kwargs): + return Plan(steps=[PlanStep(title="Open and verify sources", description="Read sources")]) + + def fake_select(*_args, **_kwargs): + return planning.ToolSelection( + tool_required=True, + tool_ids=["dummy_tool"], + rationale="narrow", + ) + + def fake_decide(_self, *_args, **kwargs): + captured["allowed"] = kwargs.get("allowed_tools") + return planning.StepDecision(decision="respond", response="ok") + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(planning.ToolSelector, "select", fake_select) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + + task_master.orchestrate_session( + "hello", + max_iters=1, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + allowed_ids = {spec.tool_id for spec in captured.get("allowed", [])} + assert "mcp_utils_internet_search_web_url_read" in allowed_ids + assert "mcp_utils_internet_search_searxng_web_search" in allowed_ids + + +def test_orchestrate_session_reflection_failure_synthesizes(monkeypatch, tmp_path): + """Synthesize a graceful response when reflection blocks progress.""" + session_store = SessionStore(root_dir=str(tmp_path)) + session_id = session_store.create_session() + + class DummyTool: + def run(self, _step): + return get_mock_speaker()(content="tool output") + + registry = ToolRegistry() + registry.register( + ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="Dummy tool", + factory=lambda: DummyTool(), + ) + ) + + def fake_generate(*_args, **_kwargs): + return make_plan("Get verified price") + + def fake_decide(*_args, **_kwargs): + return types.SimpleNamespace( + decision="tool", + tool_id="dummy_tool", + args="query", + response=None, + ) + + class DummyReflection: + status = "retry" + notes = "Missing timestamp" + revised_argument = None + + monkeypatch.setattr(Planner, "generate", fake_generate) + monkeypatch.setattr(StepExecutor, "decide", fake_decide) + monkeypatch.setattr( + StepReflector, + "reflect", + lambda *_args, **_kwargs: DummyReflection(), + ) + monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "graceful fail") + + task_queue, state = task_master.orchestrate_session( + "get price", + max_iters=1, + return_state=True, + session_id=session_id, + session_store=session_store, + tool_registry=registry, + ) + + assert state.done is True + assert state.done_reason == "incomplete" + assert task_queue.last_error is not None + assert task_queue.task_result == "graceful fail" + events = session_store.load_transcript(session_id) + assert any( + event.get("type") == "assistant" and event.get("payload", {}).get("text") == "graceful fail" + for event in events + ) + + +def test_orchestrator_max_iters_zero_marks_limit(monkeypatch, tmp_path): """Mark completion reason when max_iters is zero.""" session_store = SessionStore(root_dir=str(tmp_path)) registry = ToolRegistry() + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) orchestrator = Orchestrator( session_store=session_store, tool_registry=registry, @@ -1016,13 +1574,13 @@ def test_orchestrator_max_iters_zero_marks_limit(tmp_path): task_queue, state = orchestrator.run( "hello", max_iters=0, - initial_task_queue=TaskQueue(action_steps=[]), + initial_plan=make_plan("Do something"), return_state=True, session_id=session_store.create_session(), ) assert task_queue.action_steps == [] - assert state.done is False - assert state.done_reason == "max_iterations_reached" + assert state.done is True + assert state.done_reason == "max_steps_reached" def test_resolve_mode_plan_trigger(): @@ -1121,9 +1679,9 @@ def __init__(self): def run(self, action_step): """Return a mocked tool response.""" - self.called_with = action_step.action_argument + self.called_with = action_step.tool_input MockSpeaker = get_mock_speaker() - return MockSpeaker(content=f"ok:{action_step.action_argument}") + return MockSpeaker(content=f"ok:{action_step.tool_input}") def test_run_action_plan_permission_denied(): @@ -1142,19 +1700,19 @@ def test_run_action_plan_permission_denied(): rules=[ PermissionRule( tool_id="dummy_tool", - action_type="set", + operation="set", decision=PermissionDecision.DENY, ) ], - default_by_action={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, + default_by_operation={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, default_decision=PermissionDecision.ASK, ) task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="dummy_tool", - action_type="set", - action_argument="payload", + tool_id="dummy_tool", + operation="set", + tool_input="payload", ) ] ) @@ -1169,7 +1727,7 @@ def test_run_action_plan_permission_denied(): def test_run_action_plan_hooks_modify_input(): - """Allow hooks to modify action arguments.""" + """Allow hooks to modify tool inputs.""" registry = ToolRegistry() dummy_tool = DummyTool() registry.register( @@ -1182,25 +1740,25 @@ def test_run_action_plan_hooks_modify_input(): ) policy = PermissionPolicy( rules=[], - default_by_action={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ALLOW}, + default_by_operation={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ALLOW}, default_decision=PermissionDecision.ALLOW, ) def pre_hook(step): - step.action_argument = "updated" + step.tool_input = "updated" return step def post_hook(step, result): MockSpeaker = get_mock_speaker() - return MockSpeaker(content=f"post:{step.action_argument}") + return MockSpeaker(content=f"post:{step.tool_input}") hooks = HookManager(pre_tool_use=[pre_hook], post_tool_use=[post_hook]) task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="dummy_tool", - action_type="get", - action_argument="original", + tool_id="dummy_tool", + operation="get", + tool_input="original", ) ] ) @@ -1233,9 +1791,9 @@ def run(self, action_step): task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="boom_tool", - action_type="set", - action_argument="go", + tool_id="boom_tool", + operation="set", + tool_input="go", ) ] ) @@ -1279,9 +1837,9 @@ class DummyReflection: task_queue = TaskQueue( action_steps=[ ActionStep( - action_consumer="dummy_tool", - action_type="set", - action_argument="original", + tool_id="dummy_tool", + operation="set", + tool_input="original", ) ] ) @@ -1291,7 +1849,10 @@ class DummyReflection: approval_callback=lambda _: True, model_name="gpt-3.5-turbo", ) - assert task_queue.action_steps[0].result is None + assert task_queue.action_steps[0].result is not None + assert task_queue.action_steps[0].result.content == "ok:original" + assert task_queue.last_error is not None + assert "needs revision" in task_queue.last_error def test_orchestrate_session_auto_compact(monkeypatch, tmp_path): @@ -1301,17 +1862,10 @@ def test_orchestrate_session_auto_compact(monkeypatch, tmp_path): set_config_override({"token_budget": {"auto_compact_threshold": 0.0}}) def fake_generate(*_args, **_kwargs): - return make_task_queue("say hi") - - def fake_run(_self, task_queue): - MockSpeaker = get_mock_speaker() - task_queue.action_steps[0].result = MockSpeaker(content="done") - task_queue.task_result = "done" - return task_queue + return Plan(steps=[]) monkeypatch.setattr(Planner, "generate", fake_generate) - monkeypatch.setattr(ActionPlanRunner, "run", fake_run) - monkeypatch.setattr(ResponseSynthesizer, "synthesize", lambda *_a, **_k: "done") + monkeypatch.setattr(Orchestrator, "_should_synthesize_response", lambda *_a, **_k: False) task_master.orchestrate_session( "hello", diff --git a/tests/test_permissions.py b/tests/test_permissions.py index f71e77d6..2671b117 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -18,13 +18,13 @@ def test_default_policy_allows_get(): """Allow default get actions under permissive policy.""" policy = PermissionPolicy( rules=[], - default_by_action={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, + default_by_operation={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, default_decision=PermissionDecision.ASK, ) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="lights", + tool_id="home_assistant_tool", + operation="get", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.ALLOW @@ -35,17 +35,17 @@ def test_rule_override_denies(): rules=[ PermissionRule( tool_id="home_assistant_tool", - action_type="set", + operation="set", decision=PermissionDecision.DENY, ) ], - default_by_action={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, + default_by_operation={"get": PermissionDecision.ALLOW, "set": PermissionDecision.ASK}, default_decision=PermissionDecision.ASK, ) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="heater", + tool_id="home_assistant_tool", + operation="set", + tool_input="heater", ) assert policy.decide(step) == PermissionDecision.DENY @@ -54,13 +54,13 @@ def test_default_decision_fallback(): """Use default decision when no rule or action-specific match exists.""" policy = PermissionPolicy( rules=[], - default_by_action={}, + default_by_operation={}, default_decision=PermissionDecision.DENY, ) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="custom", - action_argument="lights", + tool_id="home_assistant_tool", + operation="custom", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.DENY @@ -72,9 +72,9 @@ def test_load_policy_from_json(tmp_path, monkeypatch): """ { "rules": [ - {"tool_id": "home_assistant_tool", "action_type": "set", "decision": "deny"} + {"tool_id": "home_assistant_tool", "operation": "set", "decision": "deny"} ], - "default_by_action": {"get": "allow", "set": "ask"}, + "default_by_operation": {"get": "allow", "set": "ask"}, "default_decision": "ask" } """, @@ -83,9 +83,9 @@ def test_load_policy_from_json(tmp_path, monkeypatch): set_config_override({"permissions": {"policy_path": str(policy_path)}}) policy = load_permission_policy() step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="lights", + tool_id="home_assistant_tool", + operation="set", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.DENY @@ -97,10 +97,10 @@ def test_load_policy_from_toml(tmp_path, monkeypatch): """ [[rules]] tool_id = "home_assistant_tool" - action_type = "set" + operation = "set" decision = "deny" - [default_by_action] + [default_by_operation] get = "allow" set = "ask" @@ -111,9 +111,9 @@ def test_load_policy_from_toml(tmp_path, monkeypatch): set_config_override({"permissions": {"policy_path": str(policy_path)}}) policy = load_permission_policy() step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="lights", + tool_id="home_assistant_tool", + operation="set", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.DENY @@ -125,9 +125,9 @@ def test_load_policy_skips_invalid_rules_and_defaults(tmp_path, monkeypatch): """ { "rules": [ - {"tool_id": "home_assistant_tool", "action_type": "set", "decision": "maybe"} + {"tool_id": "home_assistant_tool", "operation": "set", "decision": "maybe"} ], - "default_by_action": {}, + "default_by_operation": {}, "default_decision": "unknown" } """, @@ -136,9 +136,9 @@ def test_load_policy_skips_invalid_rules_and_defaults(tmp_path, monkeypatch): set_config_override({"permissions": {"policy_path": str(policy_path)}}) policy = load_permission_policy() step = ActionStep( - action_consumer="home_assistant_tool", - action_type="set", - action_argument="lights", + tool_id="home_assistant_tool", + operation="set", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.ASK @@ -155,9 +155,9 @@ def test_load_policy_missing_file(monkeypatch): set_config_override({"permissions": {"policy_path": "/tmp/missing-policy.json"}}) policy = load_permission_policy() step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="lights", + tool_id="home_assistant_tool", + operation="get", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.ALLOW @@ -169,9 +169,9 @@ def test_load_policy_invalid_json(tmp_path, monkeypatch): set_config_override({"permissions": {"policy_path": str(policy_path)}}) policy = load_permission_policy() step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="lights", + tool_id="home_assistant_tool", + operation="get", + tool_input="lights", ) assert policy.decide(step) == PermissionDecision.ALLOW @@ -181,18 +181,12 @@ def test_approval_callback_from_config(monkeypatch): set_config_override({"permissions": {"approval_mode": "allow"}}) callback = approval_callback_from_config() assert callback is not None - assert callback( - ActionStep(action_consumer="home_assistant_tool", action_type="get", action_argument="x") - ) + assert callback(ActionStep(tool_id="home_assistant_tool", operation="get", tool_input="x")) set_config_override({"permissions": {"approval_mode": "deny"}}) callback = approval_callback_from_config() assert callback is not None assert ( - callback( - ActionStep( - action_consumer="home_assistant_tool", action_type="get", action_argument="x" - ) - ) + callback(ActionStep(tool_id="home_assistant_tool", operation="get", tool_input="x")) is False ) set_config_override({"permissions": {"approval_mode": "maybe"}}) @@ -202,9 +196,9 @@ def test_approval_callback_from_config(monkeypatch): def test_auto_approve_and_deny(): """Cover explicit approve/deny helpers.""" step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="lights", + tool_id="home_assistant_tool", + operation="get", + tool_input="lights", ) assert auto_approve(step) is True assert auto_deny(step) is False diff --git a/tests/test_planning_examples.py b/tests/test_planning_examples.py index 861004b5..4b0c21c8 100644 --- a/tests/test_planning_examples.py +++ b/tests/test_planning_examples.py @@ -1,8 +1,11 @@ """Tests for planner example message wrappers.""" +from contextlib import contextmanager + from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.runnables import RunnableLambda from meeseeks_core import planning as planning_module -from meeseeks_core.classes import TaskQueue +from meeseeks_core.classes import Plan from meeseeks_core.planning import Planner from meeseeks_core.tool_registry import ToolRegistry, ToolSpec @@ -60,7 +63,7 @@ def __or__(self, _other): def invoke(self, *_args, **kwargs): captured["config"] = kwargs.get("config") - return TaskQueue(action_steps=[]) + return Plan(steps=[]) class DummyPrompt: def __init__(self, *args, **kwargs): @@ -88,3 +91,52 @@ def get_format_instructions(self): config = captured.get("config") assert config is not None assert "callbacks" in config + + +def test_planner_generate_uses_tool_specs_and_updates_span(monkeypatch): + """Use provided tool specs and update langfuse span output.""" + registry = ToolRegistry() + spec = ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="Test", + factory=lambda: object(), + ) + planner = Planner(registry) + captured: dict[str, object] = {} + + def fake_build(_prompt, _context, **kwargs): + captured["tool_specs"] = kwargs.get("tool_specs") + return "prompt" + + planner._prompt_builder.build = fake_build # type: ignore[assignment] + + class DummySpan: + def __init__(self): + self.updates: list[dict[str, object]] = [] + + def update_trace(self, **kwargs): + self.updates.append(kwargs) + + dummy_span = DummySpan() + + @contextmanager + def fake_span(_name): + yield dummy_span + + monkeypatch.setattr(planning_module, "langfuse_trace_span", fake_span) + monkeypatch.setattr(planning_module, "build_langfuse_handler", lambda **_k: None) + + def _fake_model(_inputs): + return '{"steps": []}' + + monkeypatch.setattr( + planning_module, + "build_chat_model", + lambda **_kwargs: RunnableLambda(_fake_model), + ) + + plan = planner.generate("hello", "gpt-5.2", tool_specs=[spec]) + assert plan.steps == [] + assert captured["tool_specs"] == [spec] + assert any("output" in update for update in dummy_span.updates) diff --git a/tests/test_planning_intent.py b/tests/test_planning_intent.py index 3cf42eec..2a462ae9 100644 --- a/tests/test_planning_intent.py +++ b/tests/test_planning_intent.py @@ -1,6 +1,10 @@ """Tests for intent-based tool scoping in the planner.""" -from meeseeks_core.planning import Planner +import pytest +from langchain_core.runnables import RunnableLambda +from meeseeks_core import planning +from meeseeks_core.classes import PlanStep +from meeseeks_core.planning import Planner, PlanUpdater, StepExecutor, ToolSelector from meeseeks_core.tool_registry import ToolRegistry, ToolSpec @@ -81,3 +85,102 @@ def test_spec_capabilities_infers_web_read(): planner = Planner(ToolRegistry()) spec = _spec("mcp_utils_internet_search_web_url_read", kind="mcp") assert "web_read" in planner._spec_capabilities(spec) + + +def test_tool_selector_includes_web_read_for_search(monkeypatch): + """Include web_url_read tool when web_search is selected.""" + selector = ToolSelector(ToolRegistry()) + specs = [ + _spec("mcp_utils_internet_search_searxng_web_search", kind="mcp"), + _spec("mcp_utils_internet_search_web_url_read", kind="mcp"), + ] + + def _fake_model(_inputs): + return ( + '{"tool_required": true, ' + '"tool_ids": ["mcp_utils_internet_search_searxng_web_search"], ' + '"rationale": "search"}' + ) + + monkeypatch.setattr( + planning, + "build_chat_model", + lambda **_kwargs: RunnableLambda(_fake_model), + ) + + selection = selector.select("Find recent news", "gpt-5.2", tool_specs=specs) + assert "mcp_utils_internet_search_web_url_read" in selection.tool_ids + + +def test_tool_selector_requires_registry(): + """Raise when tool registry is missing.""" + selector = ToolSelector(None) + with pytest.raises(ValueError): + selector.select("hello", "gpt-5.2", tool_specs=[]) + + +def test_step_executor_requires_registry(): + """Raise when step executor has no tool registry.""" + executor = StepExecutor(None) + with pytest.raises(ValueError): + executor.decide( + "hello", + PlanStep(title="Say hello", description="Respond to the user."), + "gpt-5.2", + allowed_tools=[], + ) + + +def test_step_executor_decides_response(monkeypatch): + """Return a parsed decision from the step executor.""" + executor = StepExecutor(ToolRegistry()) + spec = ToolSpec( + tool_id="dummy_tool", + name="Dummy", + description="test", + factory=lambda: object(), + metadata={"schema": {"type": "object"}}, + ) + + def _fake_model(_inputs): + return '{"decision": "respond", "response": "ok"}' + + monkeypatch.setattr( + planning, + "build_chat_model", + lambda **_kwargs: RunnableLambda(_fake_model), + ) + + decision = executor.decide( + "hello", + PlanStep(title="Say hello", description="Respond to the user."), + "gpt-5.2", + allowed_tools=[spec], + ) + assert decision.decision == "respond" + assert decision.response == "ok" + + +def test_plan_updater_returns_steps(monkeypatch): + """Return updated remaining steps from the plan updater.""" + updater = PlanUpdater(ToolRegistry()) + + def _fake_model(_inputs): + return '{"steps": [{"title": "Next", "description": "Do it"}]}' + + monkeypatch.setattr( + planning, + "build_chat_model", + lambda **_kwargs: RunnableLambda(_fake_model), + ) + + steps = updater.update( + "hello", + "gpt-5.2", + completed_step=PlanStep(title="Step 1", description="Do it"), + last_result=None, + remaining_steps=[], + context=None, + ) + assert steps + assert steps[0].title == "Next" diff --git a/tests/test_prompt_injection.py b/tests/test_prompt_injection.py index 5e140586..a34e3750 100644 --- a/tests/test_prompt_injection.py +++ b/tests/test_prompt_injection.py @@ -27,7 +27,7 @@ def test_prompt_excludes_home_assistant_when_disabled(monkeypatch): registry = load_registry() prompt = _build_prompt(registry) assert "Additional Devices Information" not in prompt - assert 'action_consumer="home_assistant_tool"' not in prompt + assert "home_assistant_tool" not in prompt def test_prompt_includes_home_assistant_when_enabled(monkeypatch): @@ -39,7 +39,7 @@ def test_prompt_includes_home_assistant_when_enabled(monkeypatch): registry = load_registry() prompt = _build_prompt(registry) assert "Additional Devices Information" in prompt - assert 'action_consumer="home_assistant_tool"' in prompt + assert "home_assistant_tool" in prompt def test_prompt_includes_recent_and_selected_events(monkeypatch): diff --git a/tests/test_reflection.py b/tests/test_reflection.py index 5e024d0a..49b0ee77 100644 --- a/tests/test_reflection.py +++ b/tests/test_reflection.py @@ -9,9 +9,9 @@ def test_reflect_skips_without_objective(): """Skip reflection when no objective or checklist is provided.""" reflector = StepReflector(model_name="gpt-4") step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="ping", + tool_id="home_assistant_tool", + operation="get", + tool_input="ping", ) assert reflector.reflect(step, "ok") is None @@ -21,9 +21,9 @@ def test_reflect_disabled_by_env(monkeypatch): set_config_override({"reflection": {"enabled": False}}) reflector = StepReflector(model_name="gpt-4") step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="ping", + tool_id="home_assistant_tool", + operation="get", + tool_input="ping", objective="Check status", ) assert reflector.reflect(step, "ok") is None @@ -39,9 +39,9 @@ def test_reflect_skips_without_model(monkeypatch): ) reflector = StepReflector(model_name=None) step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="ping", + tool_id="home_assistant_tool", + operation="get", + tool_input="ping", objective="Check status", ) assert reflector.reflect(step, "ok") is None @@ -75,9 +75,9 @@ def __or__(self, _other): monkeypatch.setattr("meeseeks_core.reflection.build_chat_model", lambda **_k: object()) reflector = StepReflector(model_name="gpt-4") step = ActionStep( - action_consumer="home_assistant_tool", - action_type="get", - action_argument="ping", + tool_id="home_assistant_tool", + operation="get", + tool_input="ping", objective="Check status", ) result = reflector.reflect(step, "ok") diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 407cc059..0cb92b08 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -61,7 +61,7 @@ def test_manifest_local_tool(tmp_path, monkeypatch): """Load a local tool from a manifest entry.""" module_path = tmp_path / "dummy_tool.py" module_path.write_text( - "class DummyTool:\n" " def run(self, action_step):\n" " return None\n", + "class DummyTool:\n def run(self, action_step):\n return None\n", encoding="utf-8", ) manifest_path = tmp_path / "manifest.json" diff --git a/tests/test_tools_integration.py b/tests/test_tools_integration.py index 4e159302..bd87894a 100644 --- a/tests/test_tools_integration.py +++ b/tests/test_tools_integration.py @@ -47,7 +47,7 @@ async def _fake_invoke(_): return "ok" monkeypatch.setattr(runner, "_invoke_async", _fake_invoke) - step = types.SimpleNamespace(action_argument="ping") + step = types.SimpleNamespace(tool_input="ping") result = runner.run(step) assert result.content == "ok" @@ -488,7 +488,7 @@ def invoke(self, *args, **kwargs): return DummyCall() monkeypatch.setattr(ha, "call_service", lambda **kwargs: (True, {"ok": True})) - step = types.SimpleNamespace(action_argument="turn on lamp") + step = types.SimpleNamespace(tool_input="turn on lamp") result = ha._invoke_service_and_set_state(DummyChain(), [], step) assert "Successfully called service" in result.content @@ -522,7 +522,7 @@ def get_format_instructions(self): "_invoke_service_and_set_state", lambda *a, **k: get_mock_speaker()(content="ok"), ) - step = types.SimpleNamespace(action_argument="turn on") + step = types.SimpleNamespace(tool_input="turn on") result = ha.set_state(step) assert result.content == "ok" @@ -543,7 +543,7 @@ def invoke(self, *args, **kwargs): "meeseeks_tools.integration.homeassistant.ha_render_system_prompt", lambda *args, **kwargs: "prompt", ) - step = types.SimpleNamespace(action_argument="status") + step = types.SimpleNamespace(tool_input="status") result = ha.get_state(step) assert result.content == "answer" diff --git a/uv.lock b/uv.lock index dedceeb5..44aae8b9 100644 --- a/uv.lock +++ b/uv.lock @@ -207,14 +207,14 @@ wheels = [ [[package]] name = "astroid" -version = "3.1.0" +version = "3.3.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/b9/f11533eed9b65606fb02f1b0994d8ed0903358bc55a6b9759e42f1134725/astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4", size = 396275, upload-time = "2024-02-23T16:28:12.889Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/1c/ee18acf9070f77253954b7d71b4c0cf8f5969fb23067d8f1a8793573ba00/astroid-3.1.0-py3-none-any.whl", hash = "sha256:951798f922990137ac090c53af473db7ab4e70c770e6d7fae0cec59f74411819", size = 275596, upload-time = "2024-02-23T16:28:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, ] [[package]] @@ -237,15 +237,15 @@ wheels = [ [[package]] name = "autopep8" -version = "2.1.0" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycodestyle" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/65/d187da76e65c358654a1bcdc4cbeb85767433e1e3eb67c473482301f2416/autopep8-2.1.0.tar.gz", hash = "sha256:1fa8964e4618929488f4ec36795c7ff12924a68b8bf01366c094fc52f770b6e7", size = 88891, upload-time = "2024-03-17T10:47:33.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/30873d2b7b57dee9263e53d142da044c4600a46f2d28374b3e38b023df16/autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", size = 92210, upload-time = "2025-01-14T14:46:18.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/09/87d32f364e09faebd126b2e52609182ce71ecc2ccf7e6daf8889704756b7/autopep8-2.1.0-py2.py3-none-any.whl", hash = "sha256:2bb76888c5edbcafe6aabab3c47ba534f5a2c2d245c2eddced4a30c4b4946357", size = 44957, upload-time = "2024-03-17T10:44:22.275Z" }, + { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] [[package]] @@ -614,62 +614,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" 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/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] @@ -798,16 +798,16 @@ wheels = [ [[package]] name = "flake8" -version = "7.0.0" +version = "7.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/3c/3464b567aa367b221fa610bbbcce8015bf953977d21e52f2d711b526fb48/flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", size = 48219, upload-time = "2024-01-05T00:41:52.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/01/cc8cdec7b61db0315c2ab62d80677a138ef06832ec17f04d87e6ef858f7f/flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3", size = 57570, upload-time = "2024-01-05T00:41:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] [[package]] @@ -1619,6 +1619,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/2d/2389e65522ebeab17489df72b4fabcfc661fced8af178aa6c2bc3b9afff5/langsmith-0.6.8-py3-none-any.whl", hash = "sha256:d17da18aeef15fdb4c3baec348bad64056591d785629cd5ba4846fd93cab166b", size = 319165, upload-time = "2026-02-02T23:20:00.456Z" }, ] +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -1847,7 +1920,7 @@ wheels = [ [[package]] name = "meeseeks-api" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "apps/meeseeks_api" } dependencies = [ { name = "flask" }, @@ -1860,13 +1933,13 @@ dependencies = [ requires-dist = [ { name = "flask", specifier = ">=3.0.3,<4.0.0" }, { name = "flask-restx", specifier = ">=1.3.0,<2.0.0" }, - { name = "meeseeks-core", specifier = ">=2.1.0a0" }, - { name = "meeseeks-tools", specifier = ">=2.1.0a0" }, + { name = "meeseeks-core", specifier = ">=0.0.7" }, + { name = "meeseeks-tools", specifier = ">=0.0.7" }, ] [[package]] name = "meeseeks-chat" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "apps/meeseeks_chat" } dependencies = [ { name = "meeseeks-core" }, @@ -1876,14 +1949,14 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "meeseeks-core", specifier = ">=2.1.0a0" }, - { name = "meeseeks-tools", specifier = ">=2.1.0a0" }, + { name = "meeseeks-core", specifier = ">=0.0.7" }, + { name = "meeseeks-tools", specifier = ">=0.0.7" }, { name = "streamlit", specifier = ">=1.34.0,<2.0.0" }, ] [[package]] name = "meeseeks-cli" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "apps/meeseeks_cli" } dependencies = [ { name = "meeseeks-core" }, @@ -1895,8 +1968,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "meeseeks-core", specifier = ">=2.1.0a0" }, - { name = "meeseeks-tools", specifier = ">=2.1.0a0" }, + { name = "meeseeks-core", specifier = ">=0.0.7" }, + { name = "meeseeks-tools", specifier = ">=0.0.7" }, { name = "prompt-toolkit", specifier = ">=3.0.47,<4.0.0" }, { name = "rich", specifier = ">=14.2.0,<15.0.0" }, { name = "textual", specifier = ">=7.5.0,<8.0.0" }, @@ -1904,7 +1977,7 @@ requires-dist = [ [[package]] name = "meeseeks-core" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "packages/meeseeks_core" } dependencies = [ { name = "jinja2" }, @@ -1926,7 +1999,7 @@ requires-dist = [ { name = "langchain", specifier = ">=1.0.0,<2.0.0" }, { name = "langchain-community", specifier = ">=0.4.1,<0.5.0" }, { name = "langchain-core", specifier = ">=1.2.8,<2.0.0" }, - { name = "langchain-litellm", specifier = ">=0.4.0,<0.5.0" }, + { name = "langchain-litellm", specifier = ">=0.4.0,<0.6.0" }, { name = "langfuse", specifier = ">=3.8.0,<4.0.0" }, { name = "litellm", specifier = ">=1.81.0,<2.0.0" }, { name = "loguru", specifier = ">=0.7.2,<1.0.0" }, @@ -1937,7 +2010,7 @@ requires-dist = [ [[package]] name = "meeseeks-ha-conversation" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "meeseeks_ha_conversation" } dependencies = [ { name = "aiohttp" }, @@ -1956,7 +2029,7 @@ provides-extras = ["homeassistant"] [[package]] name = "meeseeks-tools" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "packages/meeseeks_tools" } dependencies = [ { name = "langchain-core" }, @@ -1985,7 +2058,7 @@ requires-dist = [ [[package]] name = "meeseeks-workspace" -version = "2.1.0a0" +version = "0.0.7" source = { editable = "." } dependencies = [ { name = "meeseeks-core" }, @@ -2041,14 +2114,14 @@ provides-extras = ["cli", "api", "chat", "ha", "tools"] [package.metadata.requires-dev] dev = [ - { name = "autopep8", specifier = "==2.1.0" }, - { name = "flake8", specifier = "==7.0.0" }, - { name = "mypy", specifier = "==1.11.2" }, - { name = "pre-commit", specifier = "==3.7.1" }, - { name = "pylint", specifier = "==3.1.0" }, - { name = "pytest", specifier = "==8.2.0" }, + { name = "autopep8", specifier = "==2.3.2" }, + { name = "flake8", specifier = "==7.3.0" }, + { name = "mypy", specifier = "==1.19.1" }, + { name = "pre-commit", specifier = "==3.8.0" }, + { name = "pylint", specifier = "==3.3.9" }, + { name = "pytest", specifier = "==8.4.2" }, { name = "pytest-cov", specifier = "==7.0.0" }, - { name = "ruff", specifier = "==0.6.9" }, + { name = "ruff", specifier = "==0.15.0" }, { name = "types-requests", specifier = "==2.32.4.20260107" }, { name = "vulture", specifier = "==2.14" }, ] @@ -2347,31 +2420,48 @@ wheels = [ [[package]] name = "mypy" -version = "1.11.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806, upload-time = "2024-08-24T22:50:11.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401, upload-time = "2024-08-24T22:49:18.929Z" }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697, upload-time = "2024-08-24T22:49:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508, upload-time = "2024-08-24T22:49:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712, upload-time = "2024-08-24T22:49:49.399Z" }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319, upload-time = "2024-08-24T22:49:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630, upload-time = "2024-08-24T22:49:51.895Z" }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973, upload-time = "2024-08-24T22:49:21.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659, upload-time = "2024-08-24T22:49:35.02Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010, upload-time = "2024-08-24T22:49:29.725Z" }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873, upload-time = "2024-08-24T22:49:40.448Z" }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335, upload-time = "2024-08-24T22:49:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119, upload-time = "2024-08-24T22:49:03.451Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856, upload-time = "2024-08-24T22:50:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066, upload-time = "2024-08-24T22:50:03.89Z" }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000, upload-time = "2024-08-24T22:49:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625, upload-time = "2024-08-24T22:50:01.842Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] @@ -2995,7 +3085,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "3.7.1" +version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -3004,9 +3094,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/46/cc214ef6514270328910083d0119d0a80a6d2c4ec8c6608c0219db0b74cf/pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a", size = 177317, upload-time = "2024-05-11T01:25:19.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/0f/d6d0b4e2f5b2933a557087fc0560371aa545a18232d4d3427eb3bb3af12e/pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5", size = 204268, upload-time = "2024-05-11T01:25:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" }, ] [[package]] @@ -3209,11 +3299,11 @@ wheels = [ [[package]] name = "pycodestyle" -version = "2.11.1" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/8f/fa09ae2acc737b9507b5734a9aec9a2b35fa73409982f57db1b42f8c3c65/pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", size = 38974, upload-time = "2023-10-12T23:39:39.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/90/a998c550d0ddd07e38605bb5c455d00fcc177a800ff9cc3dafdcb3dd7b56/pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67", size = 31132, upload-time = "2023-10-12T23:39:38.242Z" }, + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] [[package]] @@ -3388,11 +3478,11 @@ wheels = [ [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788, upload-time = "2024-01-05T00:28:47.703Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725, upload-time = "2024-01-05T00:28:45.903Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, ] [[package]] @@ -3420,7 +3510,7 @@ crypto = [ [[package]] name = "pylint" -version = "3.1.0" +version = "3.3.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -3432,9 +3522,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/1c/4a8135f77a4ec8c0a6dc1d4543dd6fee55b36bb8bf629e2bcce8a94763a9/pylint-3.1.0.tar.gz", hash = "sha256:6a69beb4a6f63debebaab0a3477ecd0f559aa726af4954fc948c51f7a2549e23", size = 1494465, upload-time = "2024-02-25T16:48:30.783Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/2b/dfcf298607c73c3af47d5a699c3bd84ba580f1b8642a53ba2a53eead7c49/pylint-3.1.0-py3-none-any.whl", hash = "sha256:507a5b60953874766d8a366e8e8c7af63e058b26345cfcb5f91f89d987fd6b74", size = 515613, upload-time = "2024-02-25T16:48:26.96Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" }, ] [[package]] @@ -3461,7 +3551,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.2.0" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3469,11 +3559,12 @@ dependencies = [ { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/9d/78b3785134306efe9329f40815af45b9215068d6ae4747ec0bc91ff1f4aa/pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f", size = 1422883, upload-time = "2024-04-27T23:34:55.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/43/6b1debd95ecdf001bc46789a933f658da3f9738c65f32db3f4e8f2a4ca97/pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233", size = 339229, upload-time = "2024-04-27T23:34:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] @@ -3926,27 +4017,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355, upload-time = "2024-10-04T13:40:28.594Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526, upload-time = "2024-10-04T13:39:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612, upload-time = "2024-10-04T13:39:26.301Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197, upload-time = "2024-10-04T13:39:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855, upload-time = "2024-10-04T13:39:33.175Z" }, - { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889, upload-time = "2024-10-04T13:39:36.867Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678, upload-time = "2024-10-04T13:39:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682, upload-time = "2024-10-04T13:39:52.141Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446, upload-time = "2024-10-04T13:39:55.783Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048, upload-time = "2024-10-04T13:39:58.845Z" }, - { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855, upload-time = "2024-10-04T13:40:01.818Z" }, - { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007, upload-time = "2024-10-04T13:40:05.384Z" }, - { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594, upload-time = "2024-10-04T13:40:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024, upload-time = "2024-10-04T13:40:11.923Z" }, - { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085, upload-time = "2024-10-04T13:40:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088, upload-time = "2024-10-04T13:40:19.168Z" }, - { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275, upload-time = "2024-10-04T13:40:22.852Z" }, - { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" }, +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] [[package]]