diff --git a/README.md b/README.md index c1cbd4dbc1..726a419f1e 100644 --- a/README.md +++ b/README.md @@ -2249,6 +2249,7 @@ Current MVP capabilities: - One-time task forms reject local times skipped by daylight-saving transitions; select another time before creating or saving the task. - Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run - Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected +- Sending `assistant_id: null` in a scheduled-task PATCH resets the task to `lead_agent`; omitting `assistant_id` preserves the current agent, including when that custom agent has since been deleted - Duplicate an existing task into the create form as an editable draft without copying its run history - Support `once`, `cron`, and `interval` schedules - Editing or duplicating an interval task preserves its saved cadence until the interval is explicitly changed, including sub-minute intervals allowed by the operator's scheduler configuration diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index d7a568b4bd..a1401a6c88 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -207,7 +207,7 @@ startup gate rejects process-local memory and JSONL event stores when - Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when the turn did not end on an unanswered Human Input Card (`ask_clarification` or the sandbox network prompt; the question is still open, so the goal stands down with `blocker: "needs_user_input"` without calling the evaluator), a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the run has not hit its token-budget hard stop (`stop_reason == "token_capped"`; the goal stands down with `stand_down_reason: "token_capped"`), the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior. - Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations. -Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs. +Scheduled-task PATCH: omitted `assistant_id` preserves the pin; explicit null resets to `lead_agent`. **Thread lifecycle**: Before changing branching, regeneration, edit replay, or archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md). diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py index 206e092aa6..b3dcf04277 100644 --- a/backend/app/gateway/routers/scheduled_tasks.py +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -277,9 +277,9 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT await _ensure_task_mutable(existing, repo) updates = body.model_dump(exclude_none=True) - if "assistant_id" in updates: + if "assistant_id" in body.model_fields_set: updates["assistant_id"] = await resolve_scheduled_task_assistant_id( - updates["assistant_id"], + body.assistant_id, user_id=str(user.id), ) if "context_mode" in updates: diff --git a/backend/tests/test_scheduled_task_router_behavior.py b/backend/tests/test_scheduled_task_router_behavior.py index f758309c3e..fdd5c9aa3a 100644 --- a/backend/tests/test_scheduled_task_router_behavior.py +++ b/backend/tests/test_scheduled_task_router_behavior.py @@ -1076,7 +1076,7 @@ def _create_request(**overrides): return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs) -async def _seed_task(repo: _Repo, **overrides): +async def _seed_task(repo: _Repo | ScheduledTaskRepository, **overrides): kwargs = { "task_id": "task-1", "user_id": "user-1", @@ -1354,6 +1354,52 @@ async def test_update_invalid_assistant_id_is_rejected(): assert "Invalid assistant_id" in exc_info.value.detail +@pytest.mark.asyncio +async def test_update_explicit_null_assistant_id_resets_to_lead_agent(): + repo = _Repo() + task = await _seed_task(repo, assistant_id="research-bot") + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=FileNotFoundError("missing"), + ) as loader: + updated = await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id=None), + ) + loader.assert_not_called() + assert updated["assistant_id"] == "lead_agent" + assert repo.items[task["id"]]["assistant_id"] == "lead_agent" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload,expected_assistant", [({"assistant_id": None}, "lead_agent"), ({}, "research-bot")], ids=["explicit-null", "omitted"]) +async def test_update_assistant_id_through_the_sql_repository(tmp_path, payload, expected_assistant): + await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path))) + try: + sf = get_session_factory() + assert sf is not None + repo = ScheduledTaskRepository(sf) + task = await _seed_task(repo, assistant_id="research-bot") + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=FileNotFoundError("missing"), + ) as loader: + updated = await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest.model_validate({"title": "Renamed", **payload}), + ) + loader.assert_not_called() + assert updated["assistant_id"] == expected_assistant + stored = await repo.get(task["id"], user_id="user-1") + assert stored is not None + assert stored["assistant_id"] == expected_assistant + assert stored["title"] == "Renamed" + finally: + await close_engine() + + @pytest.mark.asyncio async def test_update_omitting_assistant_id_keeps_existing_even_if_agent_is_gone(): # Unrelated PATCH (rename, reschedule) must not re-resolve assistant_id.