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 @@
-- 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]]