From bd54eea2d852722b85a1643ea36ae892a12c2403 Mon Sep 17 00:00:00 2001 From: JeffWu <45140512+jeffwu-1999@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:37:09 +0800 Subject: [PATCH 1/5] merge v2.6.1 hotfix release from hotfix/v2.6.1 (#3971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Fix(evaluation): run trials in runtime service (#3954) * fix(evaluation): run trials in runtime service Route trial evaluations through the authenticated Config-to-Runtime proxy and use Config's manager only for creation-stage preparation. Keep Agent execution and evaluator scoring in Runtime. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub config thread manager Keep pure-logic service import tests aligned with the Config and Runtime thread-manager split. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub runtime jwt helper * test(evaluation): cover trial proxy error paths * Fix/override delete (#3958) * Fix: override dialog only shows override values, not model defaults (deleted params no longer reappear) * Fix: custom param deletion persists (null markers), per-agent capacity overrides take effect, and edit-dialog connectivity probe uses stored api_key * Fix: rename ModelRequest.model_id to probe_model_id - model_dump() is spread into INSERT column lists, so a model_id field injected an explicit NULL primary key and broke model creation * Fix: move probe_model_id to a dedicated ModelProbeRequest subclass - ModelRequest.model_dump() is spread into INSERT column lists, so any non-column field breaks model creation (Unconsumed column names) * Fix: editing/adding a model no longer steals the occupied default-model slot - persistCustomLocalConfig now only writes the slot when it is empty (onboarding) or the submitted model already occupies it * Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots) * Revert "Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots)" This reverts commit 2376aa50ccb0f170e5412a14c5aee33798ca1f06. * Fix: VLM connectivity probe never found the local test image - the gateway adapter's relative dirname chain resolved two levels short of the package root, so every probe silently fell back to a public URL that is unreachable in offline deployments. Anchor both probe copies on nexent.__file__ so the path survives module moves. --------- Co-authored-by: ljy * cherry-pick: HITL bugfixes from PR #3948 into hotfix/v2.6.1 (#3959) * Bubfix: guarantee event order, harden chunk buffer, SSE-subscribe controller, and break adapter on terminal human_run (#3948) * fix(hitl): preserve correct event ordering between observer chunks and human_interaction requests Root cause: the worker thread writes human_interaction events synchronously via SQLAlchemy in ask_user, while model_output_thinking/parse observer messages flow through the async consumer and are flushed only on a batched threshold (32 chunks or 250ms). When the worker suspends before that flush fires, human_interaction gains a lower event_seq number than the already-buffered observer chunks, causing the SSE replay stream to show them in the wrong order. Fix: replace the plain async-for consumer loop with a manual asyncio.wait iterator using a 50ms timeout. Once the worker finishes producing model output (i.e. right before ask_user), the loop times out and flushes any buffered observer chunks to the DB first, guaranteeing they precede the subsequent human_interaction row. Empty queue idle periods are essentially zero-cost; overall DB write frequency stays on par with the original. * fix(hitl): guarantee observer chunks precede human_interaction in DB event order When the agent invokes ask_user, two independent write paths caused the human_interaction row to be persisted BEFORE model_output_thinking / parse chunks, breaking the SSE replay ordering: the worker thread writes HITL events synchronously via SQLAlchemy, while observer messages flow through the async consumer which only flushes on a batched threshold. Fix: introduce a thread-safe shared chunk buffer on RuntimeInteractionPort (port.add_chunk / port.take_chunks). The async consumer pushes every processed chunk there; the worker thread calls flush_chunks_until_idle() before dispatching any HITL event — it polls the shared buffer and waits for the async loop to drain the observer queue (20ms idle window, 500ms max wait), then persists every chunk in its own transaction. This guarantees chunk event_seq < human_interaction event_seq regardless of async scheduling latency. Also fix ImportError: openai 2.50 removed the httpx2 module. OpenAIModel now falls back from httpx2 to httpx at import time. * test(hitl): cover shared chunk buffer, flush_chunks_until_idle, and httpx2-fallback paths Add unit tests for the RuntimeInteractionPort thread-safe chunk buffer and the flush_chunks_until_idle poll loop that guarantees observer chunks precede human_interaction events in DB order. All five HITL entry points (dispatch / boundary / receipt / finish / _wait_until_ready) are verified to invoke the idle flush before opening their transaction. Also add two tests for the openai_llm httpx2 → httpx ImportError fallback introduced to support openai >= 2.50 where the httpx2 shim was removed: one covers the fallback path, one confirms httpx2 still wins when present. * fix(hitl): address 4 review comments — hard deadline, emit_in_flight, peek_chunks, try/except safety Fix 4 real issues flagged by github-code-review: 1. Non-resettable hard_deadline in flush_chunks_until_idle — previously reset on every drain, meaning a model that kept producing chunks could stall the worker forever. deadline is now computed once at entry and the sleep call clips to hard_deadline - now. 2. _emit_in_flight Event bridges the async emit path and the worker's idle poll. Without this, buffer-empty = 'persisted' was confused with buffer-empty = 'taken for emit but still in run_blocking queue'. The worker now checks both 'buffer empty for settle_ms' AND 'no emit in flight' before deciding the async side is truly idle. 3. peek_chunks() replaces the take-put-back pattern in _flush_if_due. Previously the async loop drained the buffer, decided it was not yet due, then put everything back. That transiently-empty window (16 us normally, arbitrarily long under GIL/GC/preemption) was enough for the worker's 20 ms poll to mis-fire. We now peek (read count, no drain) and only take_chunks when we actually intend to persist. 4. emit_chunks wrapped in try/except that puts drained chunks back into the shared buffer before re-raising, and finish() wraps its flush call in try/except: pass. Guarantees (a) no chunk loss on DB failure and (b) the terminal human_run row is always written even if the flush step fails. Tests added: - 12 pure-mock unit tests in test_runtime_port_chunk_buffer.py cover hard_deadline, _emit_in_flight, peek_chunks, begin_emit/end_emit, try/except path, and every HITL entry-point's flush-before-transaction. - 1 async execute_attempt integration test in new test_application_execute_attempt.py drives the full consumer loop through _flush_if_due (peek → take → begin/end_emit) and the final flush, verifying that every patch line added in application.py is hit. * perf(hitl): stop polling while SSE stream is active, fallback to 5s when disconnected When isRunning=true the EventSource already pushes human_interaction and human_execution events in real time — the 1.5s polling loop duplicated that work, hitting the DB and re-rendering the frontend for every tick. Disable polling entirely while the SSE stream is alive, and drop to 5s intervals only when the stream is closed (e.g. page load before the first run, or after a run finishes) so we can still discover WAITING_HUMAN requests that were created while the client was disconnected. Add isRunning to the useEffect dependency array so the polling cadence resets immediately when the SSE connection state changes. * perf(hitl): replace polling with SSE subscription and move snapshot off the write path Frontend — /conversation polling → /{run_id}/events SSE: - Replace the 5s conversation snapshot polling with a native EventSource subscription to the backend's /{run_id}/events SSE stream. Discovery is now one-shot: conversationId change and the agent stream pause (isRunning true→false), the exact moment a HITL run is most likely to exist. The SSE stream then keeps run state live with native auto-reconnect. - Add dual guards inside refresh() to absorb the thundering herd from adapter.onHumanInteractionEvent (fires once per HITL SSE chunk) plus our own SSE effect: (1) in-flight dedupe — one snapshot absorbs all concurrent callers and returns cached state; (2) 3s minimum interval so bursts after the in-flight resolves do not immediately re-hit DB. - Use a runRef mirror so refresh() stays stable and downstream effects do not re-run on every snapshot. - Detect terminal status inside SSE onmessage and proactively es.close() to prevent EventSource from reconnecting forever against COMPLETED runs. Backend — snapshot off the write path: - Add repository.read_only() context manager: plain SELECT without WITH FOR UPDATE, no transaction, no flush, no _expire scan. Pure reads must not contend with worker writes on the same row lock. - Add service.light_snapshot() using read_only. Retain snapshot() as a writer-path API for any future lock-held callers. - Route conversation_snapshot, run snapshot endpoint, and both snapshot calls inside stream_run() through light_snapshot. - Move expiration to the writer path: decide() still calls _expire inline before processing each request, and expire_waiting() remains the periodic scheduler sweep. Impact: conversation snapshot calls drop from 12+/min (polling) or 10+/s (burst from adapter + SSE) to at most one every 3s. Each call is now two plain SELECTs instead of a lock-held transaction with a possible write from _expire. Read and write paths are fully decoupled. * fix(hitl): detect terminal human_run in adapter and break stream so isRunning flips false After a HITL run reaches FAILED/COMPLETED, Assistant-UI's isRunning stayed true — the stop button remained visible and new messages went into the queue buffer instead of being sent normally. The root cause is that isRunning is driven entirely by the ChatModelRun generator lifetime, which only returns when the backend SSE HTTP connection closes (reader.read() -> done=true). The backend stream_run loop can hang on heartbeat even after the run is terminal when the SSE was opened during WAITING_HUMAN with attempt_active=true: the break condition requires both cursor >= event_seq AND (terminal status OR WAITING_HUMAN with attempt_active=false and empty rows). If continueHitl fires mid-flight with a stale after_event, the cursor never catches up, so the SSE stays alive forever and the generator never returns. Stop depending on the backend closing first. Inside the adapter's SSE chunk loop, detect a terminal human_run event (status in COMPLETED, FAILED, STOPPED, EXPIRED), set a hitlTerminal flag, break the inner for-loop, and let the outer while-loop exit via the same flag on the next iteration. Assistant-UI sees the generator return and flips isRunning false immediately. Only affects HITL streams — the normal non-HITL agent path never emits human_run events so this branch is never taken. * test(hitl): update mock from snapshot to light_snapshot after read-path refactor test_human_interaction_app.py still mocked service.snapshot after commit 288ae4e69 moved conversation_snapshot and the run snapshot endpoint to service.light_snapshot (read-only path, no lock, no _expire). The fixture return_value and the two assert_called_once_with/assert_not_called assertions all referenced the old method name, causing CI to fail because MagicMock.snapshot was never called. * test(hitl): raise diff coverage above the 90% merge gate Codecov reported 70.43% patch coverage (target 90%) because new error and race paths in the HITL changes had no tests. Add mocked unit tests for: leftover chunk flush in execute_attempt's finally block before the failed finish, CancelledError scope and stop-event fallbacks, RunTerminated finish race, recovery-required outcome, and chunk iterator aclose failure tolerance; runtime_port in-flight emit busy detection, chunk restoration when emit_chunks raises, and terminal status persistence on flush failure; light_snapshot/read_only service behavior with signed tenant and user scoping; and the httpx fallback when openai._base_client.httpx2 is absent. Measured locally with CI-equivalent per-file pytest isolation: patch coverage 202/202 = 100%. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * refactor(hitl-test): dedupe fake port setup to satisfy SonarCloud duplication gate SonarCloud failed the quality gate with new_duplicated_lines_density=5.2% (threshold 3%), caused solely by test_application_execute_attempt.py: the inline _Port stub in the flush test and the one in _run_execute_attempt duplicated ~69 lines (2 CPD blocks, 14.4% file density). Extract a shared _build_port_class/_make_port_factory plus a _patched_application context manager and _execute_attempt_args so both call sites reuse a single definition; drop dead code (last_flush, install/monkeypatches, unused imports) and fix the latent bare-contextmanager NameError by using contextlib.contextmanager. No behavioral change; all 8 tests pass. * fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm openai >= 2.50 removed the httpx2 shim from openai._base_client. The bare import httpx2 causes ImportError in CI and on systems with recent openai versions. This restores the try/except fallback introduced in PR #3948 commit 9521b934 and later accidentally reverted by commit d086da259. * Revert "fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm" This reverts commit 8e06ce348dfc8c34baf42c6bfa8211883d0a2c61. * 🐛 Bugfix: Fixed an issue where the sandbox container user lacked the permissions to create folders and files. (#3963) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngi… (#3962) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngineProvider - get_provider_models routed every provider through the OpenAI-compatible adapter, so ModelEngine batch import failed (wrong endpoint path /open/router/v1/models, self-signed cert, custom type taxonomy, missing per-model base_url). The dedicated class existed but was never wired in. * chore: ModelEngine catalog base_url placeholder - preset public URL is wrong for private deployments, placeholder communicates the required /open/router/v1 path format --------- Co-authored-by: ljy * [codex] fix(agent): silently retry transient model errors (#3965) * fix(agent): retry transient model failures silently * fix(model): support OpenAI httpx2 timeout client * test(model): add deterministic OpenAI-compatible mock * fix(agent): keep stream runtime within line budget * Fix: AIDP knowledge base bug fix (#3967) * Fix: editing a ModelEngine model no longer flips ssl_verify to True - the update path only checked api_key emptiness while the create path also exempts open/router URLs (ModelEngine self-signed certs). The edit dialog prefills the real key and always submits it, so any edit silently broke connectivity. Exemption now checks the payload URL with a fallback to the stored record; batch-edit groups get the same protection * refactor: extract MODEL_ENGINE_URL_MARKER constant (SonarCloud S1192) and use a placeholder domain in test fixtures - no behavior change --------- Co-authored-by: cj2026-bit <647646783@qq.com> Co-authored-by: lijiayang619 <1170349871@qq.com> Co-authored-by: ljy Co-authored-by: bernard1234 <840646206@qq.com> Co-authored-by: panyehong <91180085+YehongPan@users.noreply.github.com> Co-authored-by: Jason Wang <56037774+JasonW404@users.noreply.github.com> Co-authored-by: gs-aion --- backend/agents/create_agent_info.py | 74 +- backend/apps/agent_evaluation_app.py | 8 +- backend/apps/agent_evaluation_runtime_app.py | 48 +- backend/apps/human_interaction_app.py | 4 +- backend/apps/model_managment_app.py | 14 +- backend/configs/model_catalog.json | 2 +- backend/consts/model.py | 17 + backend/consts/provider.py | 5 + backend/database/human_interaction_db.py | 10 + .../ext_components/aidp/apps/aidp_mgmt_app.py | 355 +++++- .../aidp/services/aidp_access_service.py | 113 +- .../aidp/services/aidp_service.py | 678 ++++++++++- backend/management/services/agent/run.py | 55 +- backend/services/agent_evaluation_service.py | 11 +- .../services/human_interaction/application.py | 93 +- .../human_interaction/runtime_port.py | 89 ++ backend/services/human_interaction/service.py | 24 +- backend/services/model_management_service.py | 23 +- backend/services/model_provider_service.py | 20 +- backend/services/runtime_proxy_service.py | 53 + backend/utils/agent_stream_utils.py | 41 + deploy/common/start-backend.sh | 7 + deploy/docker/compose/docker-compose.prod.yml | 8 +- deploy/docker/compose/docker-compose.yml | 8 +- .../agents/components/agent-prompt.tsx | 38 +- .../chat/streaming/chatStreamHandler.tsx | 103 +- .../components/model/ModelAddDialogV2.tsx | 18 + .../model/ModelAdvancedSettings.tsx | 72 ++ .../adapter/remote-chat-model-adapter.ts | 102 +- frontend/const/knowledgeBase.ts | 5 + .../aidp/components/AidpCreateKbModal.tsx | 1009 +++++++---------- .../aidp/components/AidpDocumentList.tsx | 570 +++++++--- .../AidpKnowledgeBaseModalParts.tsx | 162 +++ .../components/AidpKnowledgeConfiguration.tsx | 343 ++++-- .../aidp/components/AidpKnowledgeList.tsx | 502 +++++--- .../aidp/components/AidpUpdateKbModal.tsx | 201 ++-- .../aidp/hooks/useAidpGroupOptions.ts | 30 + .../aidp/services/aidpKnowledgeService.ts | 166 ++- .../aidp/services/aidpUploadUtils.ts | 15 + .../useHumanInteractionController.ts | 145 ++- frontend/lib/aidpDocumentStatus.ts | 128 +++ frontend/lib/reasoningAccumulator.ts | 31 + frontend/public/locales/en/common.json | 52 + frontend/public/locales/zh/common.json | 52 + frontend/services/api.ts | 4 + frontend/services/modelService.ts | 6 + frontend/tests/aidpDocumentStatus.test.ts | 140 +++ frontend/tests/reasoningAccumulator.test.ts | 43 + sdk/nexent/core/agents/core_agent.py | 14 + sdk/nexent/core/agents/nexent_agent.py | 15 +- sdk/nexent/core/agents/run_agent.py | 4 + .../core/gateway/modality/vlm/openai.py | 9 +- sdk/nexent/core/model_errors.py | 92 ++ sdk/nexent/core/models/openai_llm.py | 154 ++- sdk/nexent/core/models/openai_vlm.py | 9 +- sdk/nexent/core/models/retry.py | 57 +- sdk/nexent/core/utils/observer.py | 61 +- test/backend/app/test_agent_evaluation_app.py | 10 +- .../app/test_agent_evaluation_runtime_app.py | 54 +- .../backend/app/test_evaluation_delete_app.py | 2 +- .../backend/app/test_human_interaction_app.py | 15 +- .../services/test_agent_evaluation_service.py | 8 +- .../test_agent_model_attempt_persistence.py | 172 +++ .../test_application_execute_attempt.py | 421 +++++++ .../services/test_evaluation_pure_logic.py | 1 + .../test_human_interaction_snapshot.py | 150 +++ .../services/test_human_interaction_stream.py | 6 +- .../services/test_model_management_service.py | 59 + .../services/test_model_provider_service.py | 8 +- .../test_runtime_port_chunk_buffer.py | 356 ++++++ .../services/test_runtime_proxy_service.py | 128 +++ test/common/openai_compatible_mock_server.py | 430 +++++++ .../test_openai_compatible_mock_server.py | 172 +++ .../mock_servers/aidp_mgmt_mock_server.py | 414 ++++++- .../aidp/test_aidp_access_service.py | 22 + .../ext_components/aidp/test_aidp_mgmt_app.py | 482 +++++++- test/ext_components/aidp/test_aidp_service.py | 883 ++++++++++++++- test/sdk/core/agents/test_core_agent.py | 52 + test/sdk/core/agents/test_nexent_agent.py | 32 +- test/sdk/core/agents/test_subagent_wrapper.py | 21 + .../core/models/test_model_silent_retry.py | 37 + test/sdk/core/models/test_openai_llm.py | 161 ++- .../utils/test_observer_model_attempts.py | 66 ++ 83 files changed, 8807 insertions(+), 1467 deletions(-) create mode 100644 frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx create mode 100644 frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts create mode 100644 frontend/ext_components/aidp/services/aidpUploadUtils.ts create mode 100644 frontend/lib/aidpDocumentStatus.ts create mode 100644 frontend/tests/aidpDocumentStatus.test.ts create mode 100644 sdk/nexent/core/model_errors.py create mode 100644 test/backend/services/test_agent_model_attempt_persistence.py create mode 100644 test/backend/services/test_application_execute_attempt.py create mode 100644 test/backend/services/test_human_interaction_snapshot.py create mode 100644 test/backend/services/test_runtime_port_chunk_buffer.py create mode 100644 test/common/openai_compatible_mock_server.py create mode 100644 test/common/test_openai_compatible_mock_server.py create mode 100644 test/sdk/core/models/test_model_silent_retry.py create mode 100644 test/sdk/core/utils/test_observer_model_attempts.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 4a50f3c34b..7c404a70d0 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -274,6 +274,45 @@ def _operator_overrides_from_model_info(model_info: Optional[dict]) -> dict: return overrides +def _agent_capacity_overrides( + agent_info: Optional[dict], + model_id: Optional[int], +) -> Dict[str, Any]: + """Extract per-agent capacity overrides for the selected model. + + v2.6.0 model_params_override entries may carry capacity fields + (context_window_tokens / max_input_tokens / max_output_tokens / + default_output_reserve_tokens / tokenizer_family) next to inference + params. When present they win over the model-level capacity columns in + W1/W2 resolution, mirroring how temperature/top_p overrides win. + """ + if not isinstance(agent_info, dict) or model_id is None: + return {} + override_map = agent_info.get("model_params_override") + if not isinstance(override_map, dict): + return {} + entry = override_map.get(str(model_id)) + if not isinstance(entry, dict): + return {} + overrides: Dict[str, Any] = {} + for field in _OPERATOR_OVERRIDE_FIELDS: + value = entry.get(field) + if value is not None: + overrides[field] = value + # Per-agent override semantics: a filled value simply replaces the + # model-level value for THIS agent. "最大输出Token数" is the field users + # expect to control the actual per-request max_tokens, so mirror it into + # default_output_reserve_tokens unless the user set the reserve + # explicitly. Without this, the request would keep the model-level + # reserve (4096) and the filled cap alone would change nothing visible. + if ( + "max_output_tokens" in overrides + and "default_output_reserve_tokens" not in overrides + ): + overrides["default_output_reserve_tokens"] = overrides["max_output_tokens"] + return overrides + + def _dominant_capacity_source(field_sources: dict) -> Optional[str]: values = [value for value in field_sources.values() if value] if not values: @@ -363,6 +402,7 @@ def _resolve_context_budget( def _resolve_input_budget( model_info: Optional[dict], + capacity_overrides: Optional[Dict[str, Any]] = None, ) -> tuple[int, Optional[dict], Optional[ModelCapacitySnapshot]]: """Resolve the context-manager input budget for a model_record_t row. @@ -371,6 +411,9 @@ def _resolve_input_budget( Falls back to _TOKEN_THRESHOLD_LEGACY_FALLBACK with no snapshot when capacity is unknown - this is the migration-window behavior before all model rows are backfilled. + + capacity_overrides carries per-agent capacity fields (from + model_params_override) that win over the model-level columns. """ if not isinstance(model_info, dict): return _TOKEN_THRESHOLD_LEGACY_FALLBACK, None, None @@ -383,10 +426,13 @@ def _resolve_input_budget( "model_factory/provider is missing; capacity catalog matching is disabled" ) try: + operator_overrides = _operator_overrides_from_model_info(model_info) + if capacity_overrides: + operator_overrides.update(capacity_overrides) snapshot = resolve_capacity( model_id=model_id, provider=provider, - operator_overrides=_operator_overrides_from_model_info(model_info), + operator_overrides=operator_overrides, capability_profiles=CAPABILITY_CATALOG, ) logger.debug( @@ -1423,9 +1469,14 @@ async def create_agent_config( # W1 step 6: derive input budget via ModelCapacityResolver instead of # treating model_info["max_tokens"] (a deprecated output cap) as a # context threshold. Falls back to a safe constant when capacity is - # unknown during the migration window. + # unknown during the migration window. Per-agent capacity overrides + # (model_params_override) win over the model-level columns. input_budget, capacity_snapshot, resolved_capacity_snapshot = ( - _resolve_input_budget(model_info) + _resolve_input_budget( + model_info, + capacity_overrides=_agent_capacity_overrides( + agent_info, model_id_to_use), + ) ) else: model_name = "main_model" @@ -2297,12 +2348,27 @@ async def create_agent_run_info( mc.temperature = override_entry["temperature"] if override_entry.get("top_p") is not None: mc.top_p = override_entry["top_p"] + # v2.6.0: capacity fields are overridable per-agent. The + # authoritative consumer for request shaping is the W1/W2 + # resolution (agent-selected model), this keeps each + # ModelConfig consistent with its override entry. + for field in _OPERATOR_OVERRIDE_FIELDS: + if override_entry.get(field) is not None: + setattr(mc, field, override_entry[field]) override_extra = override_entry.get("extra_params") if override_extra and isinstance(override_extra, dict): merged = dict(mc.extra_body or {}) for k, v in override_extra.items(): if k == "__custom__" and isinstance(v, dict): - merged.update(v) + for custom_key, custom_value in v.items(): + # A null custom value is an explicit + # removal marker: the agent opts out of a + # model-level custom param instead of + # inheriting it. + if custom_value is None: + merged.pop(custom_key, None) + else: + merged[custom_key] = custom_value else: merged[k] = v mc.extra_body = merged if merged else None diff --git a/backend/apps/agent_evaluation_app.py b/backend/apps/agent_evaluation_app.py index 45e54499bc..23e872b0e0 100644 --- a/backend/apps/agent_evaluation_app.py +++ b/backend/apps/agent_evaluation_app.py @@ -19,9 +19,9 @@ get_evaluation_stats_impl, list_agent_evaluation_cases_impl, list_agent_evaluations_by_agent_impl, - trial_run_evaluator_impl, ) from services.evaluation_report_service import generate_agent_evaluation_report_impl +from services.runtime_proxy_service import forward_agent_evaluation_trial_run from utils.auth_utils import get_current_user_id, get_current_user_info @@ -552,15 +552,15 @@ async def trial_run_api( """ try: user_id, tenant_id = get_current_user_id(authorization) - result = await trial_run_evaluator_impl( - tenant_id=tenant_id, - user_id=user_id, + result = await forward_agent_evaluation_trial_run( agent_id=payload.agent_id, agent_version_no=payload.agent_version_no, query=payload.query, judge_model_id=payload.judge_model_id, evaluator_ids=payload.evaluator_ids, language=payload.language, + user_id=user_id, + tenant_id=tenant_id, ) logger.info( "trial_run_api OK: tenant=%s user=%s agent_id=%s version=%s " diff --git a/backend/apps/agent_evaluation_runtime_app.py b/backend/apps/agent_evaluation_runtime_app.py index 3c098a92f5..77aada355a 100644 --- a/backend/apps/agent_evaluation_runtime_app.py +++ b/backend/apps/agent_evaluation_runtime_app.py @@ -5,6 +5,7 @@ from typing import Annotated from fastapi import APIRouter, Header, HTTPException +from nexent.core.concurrency import ManagedTaskSpec from pydantic import BaseModel, Field from consts.evaluation_status import EvalRunStatus @@ -13,7 +14,6 @@ claim_agent_evaluation_run, get_agent_evaluation, ) -from nexent.core.concurrency import ManagedTaskSpec from services.thread_lifecycle_service import runtime_thread_manager from utils.auth_utils import verify_internal_runtime_jwt @@ -28,6 +28,17 @@ class EvaluationRunRequest(BaseModel): agent_evaluation_id: int = Field(gt=0) +class TrialRunRequest(BaseModel): + """Payload used by Config service for a non-persistent trial evaluation.""" + + agent_id: int + agent_version_no: int = 1 + query: str + judge_model_id: int + evaluator_ids: list[int] | None = None + language: str = "zh" + + def _load_evaluation_executor(): """Load the evaluation service only when a runtime run is dispatched.""" from services.agent_evaluation_service import execute_agent_evaluation_run @@ -35,6 +46,41 @@ def _load_evaluation_executor(): return execute_agent_evaluation_run +def _load_trial_executor(): + """Load the trial executor only when Runtime receives a trial request.""" + from services.agent_evaluation_service import trial_run_evaluator_impl + + return trial_run_evaluator_impl + + +@router.post("/trial-run", include_in_schema=False) +async def trial_run_evaluation_api( + payload: TrialRunRequest, + authorization: Annotated[str | None, Header()] = None, +): + """Run one ad-hoc evaluation in the Runtime process.""" + try: + user_id, tenant_id = verify_internal_runtime_jwt(authorization) + except Exception as exc: + logger.warning("Rejected unauthenticated trial evaluation: %s", exc) + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Invalid internal runtime authorization", + ) from exc + + trial_run_evaluator_impl = _load_trial_executor() + return await trial_run_evaluator_impl( + tenant_id=tenant_id, + user_id=user_id, + agent_id=payload.agent_id, + agent_version_no=payload.agent_version_no, + query=payload.query, + judge_model_id=payload.judge_model_id, + evaluator_ids=payload.evaluator_ids, + language=payload.language, + ) + + @router.post("/run", include_in_schema=False, status_code=HTTPStatus.ACCEPTED) async def dispatch_evaluation_run_api( payload: EvaluationRunRequest, diff --git a/backend/apps/human_interaction_app.py b/backend/apps/human_interaction_app.py index 37a73dc658..921e1340b4 100644 --- a/backend/apps/human_interaction_app.py +++ b/backend/apps/human_interaction_app.py @@ -50,12 +50,12 @@ async def capabilities(identity=Depends(identity_dependency)): async def conversation_snapshot(conversation_id: int, identity=Depends(identity_dependency)): def read(service, tenant_id, user_id): run_id = service.repository.latest(tenant_id, user_id, conversation_id) - return service.snapshot(run_id, tenant_id, user_id) if run_id else None + return service.light_snapshot(run_id, tenant_id, user_id) if run_id else None return await _call(identity, read) @result.get("/{run_id}") async def snapshot(run_id: str, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: service.snapshot(run_id, tenant, user)) + return await _call(identity, lambda service, tenant, user: service.light_snapshot(run_id, tenant, user)) @result.get("/{run_id}/events") async def events(run_id: str, after_event: int = Query(0, ge=0), identity=Depends(identity_dependency)): diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index fe31b87bff..2c02edcef1 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -20,6 +20,7 @@ BatchCreateModelsRequest, CapacitySuggestionFields, ModelRequest, + ModelProbeRequest, ModelCapacitySuggestionRequest, ModelCapacitySuggestionResponse, ProviderModelRequest, @@ -64,6 +65,7 @@ from utils.auth_utils import get_current_user_id from consts.exceptions import TokenExpiredError from nexent.core.concurrency import run_blocking +from database.model_management_db import get_model_by_model_id # Model Catalog loader (with graceful fallback) try: @@ -580,7 +582,7 @@ async def check_model_health( @router.post("/temporary_healthcheck") async def check_temporary_model_health( - request: ModelRequest, authorization: Optional[str] = Header(None) + request: ModelProbeRequest, authorization: Optional[str] = Header(None) ): """Verify connectivity for the provided model configuration without persisting it. @@ -589,7 +591,15 @@ async def check_temporary_model_health( authorization: Bearer token header used to enforce authentication. """ try: - get_current_user_id(authorization) + _, tenant_id = get_current_user_id(authorization) + # Edit-dialog probes arrive without the api_key (the backend never + # returns the persisted key to the client, and the dialog leaves the + # field empty to "keep existing"). Fall back to the stored key so + # verifying does not require retyping it. + if request.probe_model_id is not None and request.api_key in (None, "", "sk-no-api-key"): + stored_model = get_model_by_model_id(request.probe_model_id, tenant_id=tenant_id) + if stored_model and stored_model.get("api_key"): + request.api_key = stored_model["api_key"] result = await verify_model_config_connectivity(request.model_dump()) if result.get("connectivity") is True: # suggest_capacity may now issue an LLM self-report HTTP call diff --git a/backend/configs/model_catalog.json b/backend/configs/model_catalog.json index 7e0527a5ad..3740d86a9a 100644 --- a/backend/configs/model_catalog.json +++ b/backend/configs/model_catalog.json @@ -384,7 +384,7 @@ }, "modelengine": { "display_name": "ModelEngine", - "base_url": "https://api.modelengine-ai.net/v1/", + "base_url": "https:///open/router/v1", "models": { "qwen3-8b": { "model_type": "llm", diff --git a/backend/consts/model.py b/backend/consts/model.py index 9a01ef055b..8f2a9a7872 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -580,6 +580,23 @@ class ModelRequest(BaseModel): accepted_capability_profile_version: Optional[str] = None +class ModelProbeRequest(ModelRequest): + """Request payload for POST /model/temporary_healthcheck only. + + Extends ModelRequest with probe-only fields. Never send this to the + create/update endpoints: they spread model_dump() straight into + INSERT/UPDATE column lists, so any field that is not a real + model_record_t column makes SQLAlchemy raise "Unconsumed column names" + (and a field matching a column name would inject wrong data — the + model_id NULL primary-key bug). + """ + # Edit-dialog connectivity probes omit the stored api_key (the backend + # never returns it to the client). When set, /temporary_healthcheck falls + # back to the persisted key for this model instead of probing with the + # "sk-no-api-key" placeholder. + probe_model_id: Optional[int] = None + + class CapacitySuggestionFields(BaseModel): context_window_tokens: Optional[int] = None max_input_tokens: Optional[int] = None diff --git a/backend/consts/provider.py b/backend/consts/provider.py index fe49332b7c..63ffb15f2d 100644 --- a/backend/consts/provider.py +++ b/backend/consts/provider.py @@ -26,3 +26,8 @@ class ProviderEnum(str, Enum): # ModelEngine # Base URL and API key are loaded from environment variables at runtime +# URL path segment identifying ModelEngine northbound endpoints +# (e.g. https://host:port/open/router/v1). Endpoints behind this marker +# serve self-signed certificates, so their records must keep +# ssl_verify=False on both the create and update paths. +MODEL_ENGINE_URL_MARKER = "open/router" diff --git a/backend/database/human_interaction_db.py b/backend/database/human_interaction_db.py index 6269f5743e..19575bc4ea 100644 --- a/backend/database/human_interaction_db.py +++ b/backend/database/human_interaction_db.py @@ -168,6 +168,16 @@ def transaction(self, run_id, tenant_id=None, user_id=None): if tx: tx.flush() + @contextmanager + def read_only(self, run_id, tenant_id=None, user_id=None): + """Snapshot reads must not contend with write paths. No lock, no writes.""" + with self.session_factory() as session: + conditions = [HumanRun.run_id == run_id, HumanRun.delete_flag == "N"] + if tenant_id is not None: + conditions.extend([HumanRun.tenant_id == tenant_id, HumanRun.user_id == user_id]) + run = session.scalar(select(HumanRun).where(*conditions)) + yield RunTransaction(session, run, self.validator, user_id) if run else None + @contextmanager def creation(self, run): with self.session_factory() as session, session.no_autoflush: diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 1b4bccefcb..5fa9fe224b 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -18,28 +18,27 @@ import time from http import HTTPStatus from typing import Annotated, List, Optional +from uuid import UUID -from fastapi import APIRouter, File, Path, Query, Request, UploadFile -from fastapi.responses import JSONResponse +from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile +from fastapi.responses import JSONResponse, StreamingResponse +from nexent.core.concurrency import run_blocking from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError -from nexent.core.concurrency import run_blocking +from starlette.background import BackgroundTask from consts.const import AIDP_API_KEY, AIDP_SERVER_URL from consts.error_code import ErrorCode from consts.exceptions import AppException, UnauthorizedError from database.user_tenant_db import get_user_role_by_tenant from ext_components.aidp.consts.aidp_exceptions import ( - AidpKbConflictError, - AidpKbNotFoundError, - AidpKbPermissionDeniedError, - AidpKbSyncError, AidpGroupValidationError, + AidpKbConflictError, ) from ext_components.aidp.database import aidp_permission_db from ext_components.aidp.services import aidp_permission_service as perms -from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings from ext_components.aidp.services.aidp_access_service import ( + get_cached_aidp_channels, get_cached_aidp_doc_count, get_cached_aidp_kb_detail, invalidate_aidp_catalog_cache, @@ -47,25 +46,32 @@ invalidate_aidp_kb_detail_cache, resolve_current_aidp_access, ) +from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings +from ext_components.aidp.services.aidp_permission_service import ( + EDIT, + PRIVATE, + READ_ONLY, + _validate_group_ids_strict, # noqa: F401 - retained as a module-level compatibility symbol +) from ext_components.aidp.services.aidp_service import ( _timestamp_to_iso, count_aidp_docs_impl, create_aidp_kb_impl, delete_aidp_kb_impl, get_aidp_kb_impl, + list_aidp_channels_impl, + list_aidp_doc_history_impl, list_aidp_docs_impl, list_aidp_models_impl, + remove_aidp_docs_impl, + stream_aidp_doc_impl, + select_aidp_channel, update_aidp_kb_impl, upload_aidp_docs_impl, ) -from ext_components.aidp.services.aidp_permission_service import ( - EDIT, - PRIVATE, - READ_ONLY, - _validate_group_ids_strict, -) from utils import auth_utils as auth_utils_module + aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -74,6 +80,14 @@ AIDP_OTHER_FILE_MAX_SIZE_BYTES = 1024 * 1024 * 1024 AIDP_SMALL_FILE_EXTENSIONS = {"txt", "xls", "xlsx", "csv"} +# AIDP document statuses (mirrors the file-history vocabulary): UPLOADING, +# PROCESSING and EXTRACTING are the stages a file walks through, COMPLETED and +# FAILED are the two terminal outcomes. Only the terminal ones end the wait, so +# every other reported status is counted as work in progress: `processing_count` +# is what keeps the frontend polling, and a build that reports a stage we do not +# know yet must not stop it early either. +_TERMINAL_DOC_STATUSES = ("COMPLETED", "FAILED") + def _upload_failure(file_name: str, reason_zh: str, reason_en: str) -> dict: return { @@ -187,6 +201,17 @@ class SetPermissionRequest(BaseModel): ) +class RemoveAidpDocumentsRequest(BaseModel): + + file_uuids: List[UUID] = Field(..., min_length=1, description="AIDP file UUIDs") + + +class DownloadAidpDocumentRequest(BaseModel): + """AIDP file selected for download.""" + + file_uuid: UUID = Field(..., description="AIDP file UUID") + + # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -243,11 +268,6 @@ def _raise_aidp_conflict(exc: IntegrityError) -> None: ) -# HTTPException is imported lazily to keep FastAPI's exception handler in -# control of the response body. -from fastapi import HTTPException # noqa: E402 (placed here to avoid editing mid-file) - - def _credentials() -> tuple[str, str]: return AIDP_SERVER_URL, AIDP_API_KEY @@ -263,8 +283,16 @@ def _is_user_role(user_id: str, tenant_id: str) -> bool: return (role or "USER").upper() == "USER" -def _current_accessible_rows(user_id: str, tenant_id: str) -> list[dict]: - """Return the current AIDP catalog intersected with local user access.""" +def _current_accessible_rows( + user_id: str, + tenant_id: str, + keyword: str | None = None, +) -> list[dict]: + """Return the current AIDP catalog intersected with local user access. + + ``keyword`` is forwarded to AIDP so the remote catalog is already narrowed + before the permission intersection runs. + """ server_url, api_key = _credentials() snapshot = resolve_current_aidp_access( server_url=server_url, @@ -272,6 +300,7 @@ def _current_accessible_rows(user_id: str, tenant_id: str) -> list[dict]: user_id=user_id, tenant_id=tenant_id, aidp_tenant_id="aidp", + keyword=keyword, ) return snapshot.accessible_rows @@ -316,6 +345,179 @@ def _load_cached_doc_count(server_url: str, api_key: str, kb_id: str) -> int: ) +# Knowledge bases whose all-status history has already been reported as +# unavailable. Documents are polled every few seconds while they process, so the +# fallback is reported once per KB instead of once per poll. +_HISTORY_FALLBACK_REPORTED: set[str] = set() + + +def _log_history_fallback(kds_id: str, reason: str) -> None: + """Report (once per KB) that the document list fell back to ingested files. + + The status column can only be filled from the history payload, so a silent + fallback shows up as a column of dashes. Logging the concrete reason keeps + the cause traceable: an AIDP build without the endpoint, a channel list whose + fields we cannot read, and a failing history request all land here. + """ + if kds_id in _HISTORY_FALLBACK_REPORTED: + logger.debug( + "AIDP file history still unavailable for KB %s (%s)", + kds_id, + reason, + ) + return + _HISTORY_FALLBACK_REPORTED.add(kds_id) + logger.warning( + "AIDP all-status file history unavailable for KB %s (%s); the document list falls " + "back to ingested files only, so files under processing stay invisible and the " + "status column stays empty", + kds_id, + reason, + ) + + +def _resolve_doc_history_channel( + server_url: str, + api_key: str, + kds_id: str, +) -> dict | None: + """Resolve the ingestion channel whose directory feeds ``kds_id``. + + Returns ``None`` when AIDP exposes no channel carrying both ``fs_id`` and a + source directory, in which case the caller keeps using the completed-files + listing. The channel payload shape is reported when resolution fails, so an + unreadable field name is visible instead of silently degrading. + """ + channels = get_cached_aidp_channels( + server_url=server_url, + api_key=api_key, + kds_id=kds_id, + loader=lambda: list_aidp_channels_impl(server_url, api_key, kds_id), + ) + channel = select_aidp_channel(channels, kds_id) + if channel is None: + _log_history_fallback( + kds_id, + "no channel exposes fs_id + a source dir (channels=%d, sample keys=%s)" + % (len(channels), sorted(channels[0].keys()) if channels else []), + ) + return channel + + +async def _load_doc_history( + server_url: str, + api_key: str, + kds_id: str, +) -> dict | None: + """Fetch the all-status document history for ``kds_id`` when available. + + Any failure degrades to the legacy completed-files listing rather than + failing the request: the channel/history endpoints are newer than the + document list and may be missing on older AIDP builds, and a knowledge base + with no matching channel must still render its ingested files. Every + degraded case is logged with the offending KB so the cause stays traceable. + """ + try: + channel = await run_blocking( + "aidp-doc-history-channel", + _resolve_doc_history_channel, + server_url, + api_key, + kds_id, + lane="control-io", + owner="config", + ) + if not channel: + # The reason is reported by ``_resolve_doc_history_channel``. + return None + history = await run_blocking( + "aidp-doc-history", + list_aidp_doc_history_impl, + server_url, + api_key, + channel["fs_id"], + channel["src_dir"], + kds_id, + lane="control-io", + owner="config", + ) + items = history.get("value") if isinstance(history, dict) else None + if isinstance(items, list) and not items: + # A resolved channel directory holding no file would blank the table + # and hide the KB's ingested files — the directory may simply not be + # where this KB's uploads live. The KB-scoped listing is always safe + # to show, so an empty history is treated as unusable rather than + # authoritative (an empty KB still renders an empty list either way). + _log_history_fallback( + kds_id, + "history returned no files for the resolved directory " + f"(fs_id={channel['fs_id']}, dir_path={channel['src_dir']})", + ) + return None + return history + except AppException as exc: + _log_history_fallback(kds_id, f"history request failed: {exc}") + return None + except Exception as exc: # noqa: BLE001 - history is an optional enhancement + _log_history_fallback(kds_id, f"unexpected history error: {exc!r}") + return None + + +def _history_sort_key(item: dict) -> tuple: + """Sort key placing the most recently uploaded file first. + + Numeric upload timestamps sort above entries that only expose an ISO + ``created_at`` string, and entries with neither sink to the bottom — the + ordering is only ever used to bring fresh uploads to the top of page 1. + """ + raw = item.get("first_upload_time") + if raw is None: + raw = item.get("create_time") + try: + return (1, float(raw)) + except (TypeError, ValueError): + created_at = item.get("created_at") + return (0, created_at) if isinstance(created_at, str) else (0, "") + + +def _paginate_history_documents(result: dict, page: int, page_size: int) -> dict: + """Slice an all-status history payload into one page. + + The history API returns the whole channel directory in one response, so the + total is exact and the document Count endpoint is not needed. Newest files + come first so an upload shows up at the top of page 1 as soon as it is + accepted, instead of only after ingestion completes. + + ``processing_count`` covers the WHOLE directory, not just the returned page: + the frontend keeps polling while it is non-zero, so a file still being + processed on another page also keeps the status column live. + """ + raw_items = result.get("value") + items = ( + [item for item in raw_items if isinstance(item, dict)] + if isinstance(raw_items, list) + else [] + ) + ordered = sorted(items, key=_history_sort_key, reverse=True) + start = (page - 1) * page_size + end = start + page_size + # Count every non-terminal status, so a file that is uploading or extracting + # keeps the frontend polling exactly like one that is being chunked. + processing_count = sum( + 1 + for item in ordered + if isinstance(item.get("status"), str) + and item["status"].strip().upper() not in _TERMINAL_DOC_STATUSES + ) + return { + "value": ordered[start:end], + "total_count": len(ordered), + "has_more": end < len(ordered), + "total_reliable": True, + "processing_count": processing_count, + } + + # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- @@ -326,21 +528,38 @@ async def list_knowledge_bases( request: Request, page: Annotated[int, Query(ge=1, description="Page number starting from 1")] = 1, page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 10, + keyword: Annotated[ + str | None, + Query(max_length=200, description="Optional name filter forwarded to AIDP"), + ] = None, ) -> JSONResponse: - """List KBs the caller can access. + """List KBs the caller can access, optionally filtered by name. Resolution order: - 1. Fetch every KB visible to the currently configured AIDP credentials. + 1. Fetch the AIDP catalog visible to the configured credentials, narrowed + server-side by ``keyword`` when one is supplied. 2. Intersect that catalog with the caller's effective Nexent permissions. 3. Paginate the intersection, then fetch details for the visible page. + + A non-blank ``keyword`` therefore narrows both the fetched set and the + reported ``total_count``: both describe the filtered, permission-scoped set. """ user_id, tenant_id = await _auth(request) server_url, api_key = _credentials() + normalized_keyword = (keyword or "").strip() or None started_at = time.perf_counter() + # ``run_blocking`` (not a bare to_thread) keeps AIDP catalog reads on the + # managed control-io lane, and the keyword is still forwarded so the remote + # catalog is narrowed server-side before the permission intersection. rows = await run_blocking( - "aidp-accessible-rows", _current_accessible_rows, user_id, tenant_id, - lane="control-io", owner="config", + "aidp-accessible-rows", + _current_accessible_rows, + user_id, + tenant_id, + normalized_keyword, + lane="control-io", + owner="config", ) access_resolve_ms = (time.perf_counter() - started_at) * 1000 total_count = len(rows) @@ -720,6 +939,28 @@ async def list_documents( server_url, api_key = _credentials() started_at = time.perf_counter() + + # Preferred source: the all-status history, so files appear in the list + # while they are still being chunked/embedded (and when they failed). The + # payload covers the whole channel directory, so this branch paginates + # in-process and reports an exact total. + history_result = await _load_doc_history(server_url, api_key, kds_id) + if history_result is not None: + result = _paginate_history_documents(history_result, page, page_size) + logger.info( + "AIDP document list timing: total_ms=%.1f kb_id=%s page=%d page_size=%d " + "page_count=%d total_count=%d total_reliable=True source=history", + (time.perf_counter() - started_at) * 1000, + kds_id, + page, + page_size, + len(result["value"]), + result["total_count"], + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + # Fallback: the completed-files listing (historical behaviour), used when + # AIDP has no channel/history support for this knowledge base. list_result, count_result = await asyncio.gather( run_blocking( "aidp-list-documents", @@ -769,11 +1010,14 @@ async def list_documents( result["total_count"] = int(total_count) result["has_more"] = has_more + # The completed-files listing carries no processing statuses, so nothing + # keeps the frontend polling for this knowledge base. + result["processing_count"] = 0 if not count_reliable: result["total_reliable"] = False logger.info( "AIDP document list timing: total_ms=%.1f kb_id=%s page=%d page_size=%d " - "page_count=%d total_count=%d total_reliable=%s", + "page_count=%d total_count=%d total_reliable=%s source=completed", (time.perf_counter() - started_at) * 1000, kds_id, page, @@ -785,6 +1029,65 @@ async def list_documents( return JSONResponse(status_code=HTTPStatus.OK, content=result) +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/remove") +async def remove_documents( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: RemoveAidpDocumentsRequest, +) -> JSONResponse: + """Remove AIDP documents.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-remove-documents", + remove_aidp_docs_impl, + server_url, + api_key, + kds_id, + [str(file_uuid) for file_uuid in body.file_uuids], + lane="control-io", + owner="config", + ) + + success_list = result["success_list"] + if success_list: + invalidate_aidp_kb_detail_cache(server_url, api_key, kds_id) + invalidate_aidp_doc_count_cache(server_url, api_key, kds_id) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/download") +async def download_document( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: DownloadAidpDocumentRequest, +) -> StreamingResponse: + """Proxy an AIDP document as a binary attachment.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + aidp_response = await stream_aidp_doc_impl( + server_url, + api_key, + kds_id, + str(body.file_uuid), + ) + response_headers = { + "Content-Disposition": aidp_response.headers["Content-Disposition"], + "X-File-Size": aidp_response.headers["X-File-Size"], + } + + return StreamingResponse( + aidp_response.aiter_bytes(), + media_type=aidp_response.headers["Content-Type"], + headers=response_headers, + background=BackgroundTask(aidp_response.aclose), + ) + + @aidp_mgmt_router.patch("/aidp-permissions/{kds_id}") async def set_permission( request: Request, diff --git a/backend/ext_components/aidp/services/aidp_access_service.py b/backend/ext_components/aidp/services/aidp_access_service.py index 8ebc23e908..80b1a9b497 100644 --- a/backend/ext_components/aidp/services/aidp_access_service.py +++ b/backend/ext_components/aidp/services/aidp_access_service.py @@ -23,15 +23,26 @@ _DETAIL_CACHE_MAX_ENTRIES = 256 _DOC_COUNT_CACHE_TTL_SECONDS = 30.0 _DOC_COUNT_CACHE_MAX_ENTRIES = 256 +_CHANNELS_CACHE_TTL_SECONDS = 60.0 +_CHANNELS_CACHE_MAX_ENTRIES = 32 _catalog_cache: OrderedDict[tuple[str, str], tuple[float, list[dict]]] = OrderedDict() _detail_cache: OrderedDict[tuple[str, str, str], tuple[float, dict]] = OrderedDict() _doc_count_cache: OrderedDict[tuple[str, str, str], tuple[float, int]] = OrderedDict() +_channels_cache: OrderedDict[tuple[str, str], tuple[float, list[dict]]] = OrderedDict() _catalog_inflight: dict[tuple[str, str], Future[Any]] = {} _detail_inflight: dict[tuple[str, str, str], Future[Any]] = {} _doc_count_inflight: dict[tuple[str, str, str], Future[Any]] = {} +_channels_inflight: dict[tuple[str, str], Future[Any]] = {} _catalog_versions: dict[tuple[str, str], int] = {} _detail_versions: dict[tuple[str, str, str], int] = {} _doc_count_versions: dict[tuple[str, str, str], int] = {} +_channels_versions: dict[tuple[str, str], int] = {} +# Keyword-filtered catalogs live in their own namespace. Sharing ``_catalog_cache`` +# would let a search result overwrite the full catalog under the same key and +# silently hide KBs from every non-search caller for the rest of the TTL. +_search_catalog_cache: OrderedDict[tuple[str, str, str], tuple[float, list[dict]]] = OrderedDict() +_search_catalog_inflight: dict[tuple[str, str, str], Future[Any]] = {} +_search_catalog_versions: dict[tuple[str, str, str], int] = {} _cache_lock = threading.RLock() _T = TypeVar("_T") @@ -136,13 +147,39 @@ def _get_remote_catalog( api_key: str, aidp_tenant_id: str, force_refresh: bool, + keyword: str | None = None, ) -> list[dict]: - key = _cache_key(server_url, aidp_tenant_id) + """Return the remote KB catalog, optionally filtered server-side by keyword. + + Keyword-filtered results are cached in their own namespace. Both variants + come from the same endpoint but describe different sets, so sharing a cache + slot would let a search response mask the full catalog (or vice versa) for + the remainder of the TTL. + """ + normalized_keyword = (keyword or "").strip() + catalog_key = _cache_key(server_url, aidp_tenant_id) + + if normalized_keyword: + return _get_or_load_cached( + cache=_search_catalog_cache, + inflight=_search_catalog_inflight, + versions=_search_catalog_versions, + key=(*catalog_key, normalized_keyword.lower()), + ttl_seconds=_CATALOG_CACHE_TTL_SECONDS, + max_entries=_CATALOG_CACHE_MAX_ENTRIES, + loader=lambda: _extract_remote_items( + fetch_all_aidp_knowledge_bases_impl( + server_url, api_key, keyword=normalized_keyword + ) + ), + force_refresh=force_refresh, + ) + return _get_or_load_cached( cache=_catalog_cache, inflight=_catalog_inflight, versions=_catalog_versions, - key=key, + key=catalog_key, ttl_seconds=_CATALOG_CACHE_TTL_SECONDS, max_entries=_CATALOG_CACHE_MAX_ENTRIES, loader=lambda: _extract_remote_items( @@ -196,6 +233,43 @@ def get_cached_aidp_doc_count( ) +def get_cached_aidp_channels( + server_url: str, + api_key: str, + kds_id: str, + loader: Callable[[], Any], + aidp_tenant_id: str = "aidp", + force_refresh: bool = False, +) -> list[dict]: + """Return one knowledge base's AIDP ingestion channels with short caching. + + Channels are catalogued per knowledge base, so the KB id is part of the cache + key: without it a channel resolved for one KB would be reused for another and + the history lookup would read the wrong directory. + + Every document-list request resolves a channel, and the UI polls that list + while files are still being processed, so the result is cached to keep + polling at one upstream request per interval. A channel describes how a KB + ingests files and is not affected by uploads, so a plain TTL is enough and + there is nothing to invalidate on write. + """ + + def _load() -> list[dict]: + return _extract_remote_items(loader()) + + key = (*_cache_key(server_url, aidp_tenant_id), str(kds_id)) + return _get_or_load_cached( + cache=_channels_cache, + inflight=_channels_inflight, + versions=_channels_versions, + key=key, + ttl_seconds=_CHANNELS_CACHE_TTL_SECONDS, + max_entries=_CHANNELS_CACHE_MAX_ENTRIES, + loader=_load, + force_refresh=force_refresh, + ) + + def resolve_current_aidp_access( server_url: str, api_key: str, @@ -203,8 +277,14 @@ def resolve_current_aidp_access( tenant_id: str, aidp_tenant_id: str = "aidp", force_refresh: bool = False, + keyword: str | None = None, ) -> AidpAccessSnapshot: - """Return the current AIDP catalog intersected with local user access.""" + """Return the current AIDP catalog intersected with local user access. + + ``keyword`` narrows the remote catalog server-side before the permission + intersection runs, so the returned snapshot only contains matching KBs. + Passing ``None``/blank keeps the full catalog. + """ started_at = time.perf_counter() remote_started_at = time.perf_counter() remote_items = _get_remote_catalog( @@ -212,6 +292,7 @@ def resolve_current_aidp_access( api_key=api_key, aidp_tenant_id=aidp_tenant_id, force_refresh=force_refresh, + keyword=keyword, ) remote_ms = (time.perf_counter() - remote_started_at) * 1000 permission_started_at = time.perf_counter() @@ -267,17 +348,40 @@ def invalidate_aidp_catalog_cache( api_key: str | None = None, aidp_tenant_id: str = "aidp", ) -> None: - """Invalidate one credential-scoped catalog, or every catalog when omitted.""" + """Invalidate one credential-scoped catalog, or every catalog when omitted. + + Keyword-filtered catalogs are dropped alongside the full catalog: they are + projections of the same remote data, so leaving them behind would let a + stale search result outlive the mutation that invalidated the listing. + """ with _cache_lock: if server_url is None or api_key is None: _catalog_cache.clear() for key in set(_catalog_versions) | set(_catalog_inflight): _catalog_versions[key] = _catalog_versions.get(key, 0) + 1 + _search_catalog_cache.clear() + for search_key in set(_search_catalog_versions) | set(_search_catalog_inflight): + _search_catalog_versions[search_key] = ( + _search_catalog_versions.get(search_key, 0) + 1 + ) return key = _cache_key(server_url, aidp_tenant_id) _catalog_cache.pop(key, None) _catalog_versions[key] = _catalog_versions.get(key, 0) + 1 + # Search keys are ``(*catalog_key, keyword)``; match them by prefix. + for search_key in [ + candidate + for candidate in set(_search_catalog_cache) + | set(_search_catalog_versions) + | set(_search_catalog_inflight) + if candidate[:2] == key + ]: + _search_catalog_cache.pop(search_key, None) + _search_catalog_versions[search_key] = ( + _search_catalog_versions.get(search_key, 0) + 1 + ) + def invalidate_aidp_kb_detail_cache( server_url: str | None = None, @@ -329,6 +433,7 @@ def invalidate_aidp_doc_count_cache( __all__ = [ "AidpAccessSnapshot", + "get_cached_aidp_channels", "get_cached_aidp_doc_count", "get_cached_aidp_kb_detail", "invalidate_aidp_catalog_cache", diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index ac5cb4d37c..50400aee42 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -5,15 +5,16 @@ import logging import time from datetime import datetime, timezone -from typing import Any, Callable, Dict, List -from urllib.parse import urljoin +from typing import Any, Callable, Dict, List, NoReturn +from urllib.parse import quote, urljoin import httpx +from nexent.utils.http_client_manager import http_client_manager from consts.const import AIDP_TENANT_ID from consts.error_code import ErrorCode from consts.exceptions import AppException -from nexent.utils.http_client_manager import http_client_manager + logger = logging.getLogger("aidp_service") @@ -66,6 +67,39 @@ def _extract_upstream_error(response: httpx.Response) -> str | None: return reason[:_MAX_UPSTREAM_ERROR_REASON_LENGTH] +def _raise_aidp_http_error(error: httpx.HTTPStatusError, operation: str) -> NoReturn: + """Map an AIDP HTTP error to the common application exception format.""" + response = error.response + upstream_reason = _extract_upstream_error(response) + logger.exception( + "AIDP %s HTTP error: status_code=%s upstream_reason=%s", + operation, + response.status_code, + upstream_reason or "unavailable", + ) + details = { + "upstream_status": response.status_code, + "upstream_reason": upstream_reason, + } + error_code = { + 401: ErrorCode.AIDP_AUTH_ERROR, + 403: ErrorCode.AIDP_AUTH_ERROR, + 429: ErrorCode.AIDP_RATE_LIMIT, + }.get(response.status_code, ErrorCode.AIDP_SERVICE_ERROR) + fallback_message = { + ErrorCode.AIDP_AUTH_ERROR: f"AIDP authentication failed: {str(error)}", + ErrorCode.AIDP_RATE_LIMIT: f"AIDP rate limit exceeded: {str(error)}", + }.get( + error_code, + f"AIDP API HTTP error {response.status_code}: {str(error)}", + ) + raise AppException( + error_code, + upstream_reason or fallback_message, + details=details, + ) + + def _extract_upload_failures(response: httpx.Response) -> List[Dict[str, str]]: """Extract per-file upload failures from AIDP's structured error body.""" try: @@ -113,6 +147,48 @@ def _get_list_path(tenant_id: str | None = None) -> str: return f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}/KnowledgeBases" +def _get_channels_path(kds_id: str, tenant_id: str | None = None) -> str: + """Build the knowledge-base scoped ingestion-pipeline (channel) API path. + + AIDP exposes the channel catalog per knowledge base + (``.../KnowledgeBases/{kds_id}/Channels``), mirroring the knowledge-file + endpoints. The channel carries the ``fs_id`` + source directory the history + request is addressed with, which is why the KB id is part of the path. + """ + return ( + f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}" + f"/KnowledgeBases/{kds_id}/Channels" + ) + + +def _get_doc_history_path(kds_id: str, tenant_id: str | None = None) -> str: + """Build the knowledge-base scoped knowledge-file history API path. + + Like the channel catalog, the history endpoint belongs to one knowledge base + (``.../KnowledgeBases/{kds_id}/KnowledgeFiles/History``); the body addresses + the request to a channel directory inside that knowledge base. + """ + return ( + f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}" + f"/KnowledgeBases/{kds_id}/KnowledgeFiles/History" + ) + + +def _build_list_query(page: int, page_size: int, keyword: str | None = None) -> str: + """Build the query string for a knowledge-base list request. + + AIDP applies ``keyword`` server-side (matching on the KB name), so the + value is forwarded verbatim. The parameter is omitted entirely when no + keyword is supplied, which keeps plain listing requests byte-identical to + the pre-search behaviour (same URL, so upstream caches still hit). + """ + query = f"?page={page}&page_size={page_size}" + normalized_keyword = (keyword or "").strip() + if normalized_keyword: + query += f"&keyword={quote(normalized_keyword, safe='')}" + return query + + def _timestamp_to_iso(value: Any) -> str | None: """Convert a numeric Unix timestamp (seconds or milliseconds) to ISO-8601 UTC. @@ -140,6 +216,31 @@ def _timestamp_to_iso(value: Any) -> str | None: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") +# AIDP wraps list payloads in ``value`` (the shape every established endpoint in +# this adapter returns). The remaining keys cover builds that put the items +# behind a transport envelope, and a bare array is accepted as-is, so a renamed +# wrapper cannot silently turn a populated list into "nothing returned". +_AIDP_LIST_KEYS = ("value", "data", "result", "records", "items", "list") + + +def _extract_list_payload(payload: Any) -> list | None: + """Return the item list carried by an AIDP list response, if there is one. + + ``None`` means the response does not carry a list at all, which callers + report as an unexpected response format instead of degrading into an empty + result that looks like "no data upstream". + """ + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return None + for key in _AIDP_LIST_KEYS: + candidate = payload.get(key) + if isinstance(candidate, list): + return candidate + return None + + def _normalize_aidp_doc(raw: Dict[str, Any]) -> Dict[str, Any]: """Map an AIDP document item to the shape the frontend expects. @@ -157,6 +258,86 @@ def _normalize_aidp_doc(raw: Dict[str, Any]) -> Dict[str, Any]: return out +def _normalize_doc_status(value: Any) -> str | None: + """Normalize an AIDP file status to its canonical upper-case token. + + AIDP reports ``COMPLETED`` / ``PROCESSING`` / ``FAILED``; normalizing here + means the frontend can compare against one form regardless of upstream + capitalization. ``None`` is returned for missing/blank values so callers can + tell "no status reported" apart from a real status. Non-string values (for + example a numeric ``state``) are ignored instead of being stringified. + """ + if isinstance(value, str) and value.strip(): + return value.strip().upper() + return None + + +# Deployments spell the per-file processing status differently, so it is read +# through aliases like the channel fields further down (``_CHANNEL_*_KEYS``). +# Only the forms actually observed are listed; adding one here is enough to +# support another deployment. +_HISTORY_STATUS_KEYS = ( + "status", + "file_status", + "fileStatus", + "file_state", + "doc_status", +) + + +def _extract_doc_status(raw: Dict[str, Any]) -> str | None: + """Return the first recognized file status carried by ``raw``. + + The canonical ``status`` wins, then the observed aliases in order, so a + deployment that renames the field keeps working without a code change. + """ + for key in _HISTORY_STATUS_KEYS: + status = _normalize_doc_status(raw.get(key)) + if status is not None: + return status + return None + + +def _normalize_history_doc(raw: Dict[str, Any]) -> Dict[str, Any]: + """Map an AIDP knowledge-file history item to the frontend document shape. + + The history payload carries the same identity fields as the document list + (``file_ino_no`` / ``file_name`` / ``file_size`` / ``file_type`` / + ``first_upload_time``) plus a processing status. Whatever field the upstream + used, the value is re-emitted under the canonical ``status`` key so the + frontend only has to read one field. A blank or missing upstream status is + dropped rather than forwarded as whitespace, so callers can treat "no status + reported" as a single state. + """ + out = _normalize_aidp_doc(raw) + status = _extract_doc_status(raw) + if status is None: + out.pop("status", None) + else: + out["status"] = status + return out + + +def _warn_when_history_status_unreadable(items: List[Any], fs_id: str) -> None: + """Log the payload shape when no history item carries a usable status. + + The processing status is the reason the history endpoint is used at all: a + payload whose status field we cannot read renders as a column of dashes, so + the keys of a sample item are logged to make the real field name + discoverable from the service log instead of leaving the gap invisible. + """ + item_dicts = [item for item in items if isinstance(item, dict)] + if not item_dicts or any("status" in item for item in item_dicts): + return + logger.warning( + "AIDP file history for fs_id=%s returned %d item(s) with no readable status; " + "sample keys=%s", + fs_id, + len(item_dicts), + sorted(item_dicts[0].keys()), + ) + + def _validate_params(server_url: str, api_key: str) -> str: """Validate parameters and return normalized base URL.""" if not server_url or not isinstance(server_url, str): @@ -183,6 +364,7 @@ def _validate_params(server_url: str, api_key: str) -> str: _AIDP_RETRY_BACKOFF_FACTOR = 0.5 _AIDP_RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} _AIDP_READ_TIMEOUT_SECONDS = 30.0 +_AIDP_DOWNLOAD_TIMEOUT_SECONDS = 120.0 def _request_with_retry( @@ -260,8 +442,12 @@ def fetch_aidp_knowledge_bases_impl( api_key: str, page: int = 1, page_size: int = 10, + keyword: str | None = None, ) -> Dict[str, Any]: - """Fetch a single page from AIDP API (simple passthrough).""" + """Fetch a single page from AIDP API (simple passthrough). + + ``keyword`` is forwarded to AIDP as an optional server-side filter. + """ normalized_url = _validate_params(server_url, api_key) headers = { @@ -269,7 +455,7 @@ def fetch_aidp_knowledge_bases_impl( "Content-Type": "application/json", } - list_path = f"{_get_list_path()}?page={page}&page_size={page_size}" + list_path = f"{_get_list_path()}{_build_list_query(page, page_size, keyword)}" list_url = urljoin(f"{normalized_url}/", list_path) logger.info("Fetching AIDP knowledge bases from %s", list_url) @@ -343,9 +529,25 @@ def _normalize_response(raw: Dict[str, Any]) -> Dict[str, Any]: } +def _kb_matches_keyword(item: Dict[str, Any], keyword: str) -> bool: + """Return whether a KB row carries ``keyword`` in its name or description. + + Only used as the fallback for AIDP builds that ignore the server-side + filter, so plain case-insensitive substring semantics are enough — the + upstream match set is always trusted when the upstream actually narrowed. + """ + needle = keyword.lower() + for field in ("kds_name", "name", "description"): + value = item.get(field) + if isinstance(value, str) and needle in value.lower(): + return True + return False + + def fetch_all_aidp_knowledge_bases_impl( server_url: str, api_key: str, + keyword: str | None = None, ) -> Dict[str, Any]: """Fetch every AIDP knowledge-base page using the dedicated Count API. @@ -354,8 +556,20 @@ def fetch_all_aidp_knowledge_bases_impl( the number of pages, and every list request uses the configured tenant. Duplicate resources are removed by ``kds_id`` while preserving their first-seen order. + + ``keyword`` is forwarded to every list call so AIDP filters server-side. + The Count API has no keyword support, so the page count is deliberately + derived from the unfiltered total: AIDP returns the matching subset on the + earliest pages and empty pages afterwards, which this loop tolerates. The + caller therefore still receives every match. + + A build that ignores ``keyword`` answers with the whole catalog instead. That + is detected after the loop (the collected set is not narrower than the + reported total) and the keyword is then applied locally, so a search cannot + silently return the unfiltered list. """ normalized_url = _validate_params(server_url, api_key) + normalized_keyword = (keyword or "").strip() page_size = 100 started_at = time.perf_counter() count_started_at = time.perf_counter() @@ -395,7 +609,10 @@ def fetch_all_aidp_knowledge_bases_impl( list_started_at = time.perf_counter() for current_page in range(1, total_pages + 1): - page_path = f"{_get_list_path()}?page={current_page}&page_size={page_size}" + page_path = ( + f"{_get_list_path()}" + f"{_build_list_query(current_page, page_size, keyword)}" + ) current_url = urljoin(f"{normalized_url}/", page_path) logger.info( @@ -446,6 +663,26 @@ def fetch_all_aidp_knowledge_bases_impl( all_items.append(item) accumulated_count = len(all_items) + result_count = accumulated_count + if normalized_keyword and accumulated_count >= total_count: + # The upstream answered a keyword request with the entire catalog, so + # this AIDP build ignores the parameter. Narrow locally instead of + # reporting an unfiltered list as a search result: every KB has + # already been fetched above, so the match set stays complete. + all_items = [ + item + for item in all_items + if _kb_matches_keyword(item, normalized_keyword) + ] + result_count = len(all_items) + logger.info( + "AIDP returned the full catalog (%d of %d reported KBs) for keyword %r; " + "applied the local name/description filter -> %d match(es)", + accumulated_count, + total_count, + normalized_keyword, + result_count, + ) list_ms = (time.perf_counter() - list_started_at) * 1000 logger.info( "AIDP KB catalog timing: total_ms=%.1f count_ms=%.1f list_ms=%.1f " @@ -460,7 +697,7 @@ def fetch_all_aidp_knowledge_bases_impl( return { "value": all_items, - "total_count": accumulated_count, + "total_count": result_count, "next_link": None, } except httpx.RequestError as e: @@ -1012,6 +1249,107 @@ def upload_aidp_docs_impl( ) +def remove_aidp_docs_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuids: List[str], +) -> Dict[str, Any]: + """Remove one or more documents from an AIDP knowledge base.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + remove_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Remove" + remove_url = urljoin(f"{normalized_url}/", remove_path) + logger.info("Removing %d AIDP documents from %s", len(file_uuids), remove_url) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.post( + remove_url, + headers=headers, + json={"file_uuids": file_uuids}, + ), + context=f"remove-docs:{kds_id}", + ) + response.raise_for_status() + result = response.json() + return result + except httpx.RequestError as e: + logger.exception("AIDP document removal request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + _raise_aidp_http_error(e, "document removal") + except ValueError as e: + logger.exception("Failed to parse AIDP document removal response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +async def stream_aidp_doc_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuid: str, +) -> httpx.Response: + """Open a streaming response for one AIDP document.""" + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + download_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Download" + download_url = urljoin(f"{normalized_url}/", download_path) + logger.info("Downloading AIDP document %s from %s", file_uuid, download_url) + + response: httpx.Response | None = None + try: + client = http_client_manager.get_async_client( + base_url=normalized_url, + timeout=_AIDP_DOWNLOAD_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = await client.send( + client.build_request( + "POST", + download_url, + headers=headers, + json={"file_uuid": file_uuid}, + ), + stream=True, + ) + if response.status_code >= 400: + await response.aread() + response.raise_for_status() + return response + except httpx.RequestError as e: + if response is not None: + await response.aclose() + logger.exception("AIDP document download request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + if response is not None: + await response.aclose() + _raise_aidp_http_error(e, "document download") + + def count_aidp_docs_impl(server_url: str, api_key: str, kds_id: str) -> int: """Get total document count in a KB via AIDP POST .../Count endpoint. @@ -1172,6 +1510,332 @@ def list_aidp_docs_impl( ) +# ==================== Knowledge-file history (all-status listing) ==================== + +# Channel entries are read through alias lists because AIDP deployments spell +# these fields differently. Only the forms actually observed are listed; adding +# a new spelling here is enough to support another deployment. +_CHANNEL_FS_ID_KEYS = ( + "fs_id", + "fsId", + "fsid", + "file_system_id", + "fileSystemId", + "file_sys_id", + "filesystem_id", +) +_CHANNEL_SRC_DIR_KEYS = ( + "src_dir", + "srcDir", + "source_dir", + "sourceDir", + "source_path", + "sourcePath", + "dir_path", + "dirPath", + "input_dir", + "inputDir", +) +_CHANNEL_KB_ID_KEYS = ( + "kds_id", + "kdsId", + "knowledge_base_id", + "knowledgeBaseId", + "kb_id", + "kbId", + "knowledge_base_ids", + "knowledgeBaseIds", +) + + +def _first_non_empty_string(item: Dict[str, Any], keys: tuple) -> str | None: + """Return the first non-empty string among ``keys``, or ``None``.""" + for key in keys: + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _lookup_channel_field(item: Dict[str, Any], keys: tuple) -> str | None: + """Return the first non-empty string among ``keys``, nested dicts included. + + Deployments nest the addressing fields differently (for example under a + ``config`` or ``file_system`` object next to the channel's own keys), so one + level of nested dictionaries is inspected before giving up. Without this a + channel holding both values could look unusable purely because of nesting, + which silently drops the whole status feature back to the ingested-files + listing. + """ + found = _first_non_empty_string(item, keys) + if found is not None: + return found + for value in item.values(): + if isinstance(value, dict): + found = _first_non_empty_string(value, keys) + if found is not None: + return found + return None + + +def _references_kb_id(value: Any, kds_id: str) -> bool: + """Return whether a channel field references ``kds_id``. + + Accepts a scalar id, a list of ids, or a directory string such as + ``/knowledge/`` — a channel is bound to a knowledge base either by + an explicit id field or by pointing its source directory at that KB. + """ + if isinstance(value, str): + return value == kds_id or kds_id in value.split("/") + if isinstance(value, (list, tuple, set)): + return any(_references_kb_id(entry, kds_id) for entry in value) + return False + + +def _channel_references_kb_id(item: Dict[str, Any], kds_id: str) -> bool: + """Return whether any channel field (nested ones included) references the KB. + + Fields are matched by name first, then every remaining scalar/list value is + checked so a build that binds a channel to its knowledge base through an + undocumented field (a pipeline name, a path) still resolves. + """ + for key, value in item.items(): + if key in _CHANNEL_KB_ID_KEYS and _references_kb_id(value, kds_id): + return True + if isinstance(value, dict) and _channel_references_kb_id(value, kds_id): + return True + if isinstance(value, (str, list, tuple, set)) and _references_kb_id( + value, kds_id + ): + return True + return False + + +def select_aidp_channel( + channels: List[Dict[str, Any]], + kds_id: str | None = None, +) -> Dict[str, str] | None: + """Pick the ingestion channel whose files feed ``kds_id``. + + Selection order: + 1. a channel that references ``kds_id`` (id field or source directory), + 2. otherwise the first channel carrying both ``fs_id`` and a source dir. + + Step 2 covers deployments that keep a single channel per tenant, where the + history lookup is intentionally directory-wide. Returns ``None`` when no + channel carries the pair the history API needs, so the caller can fall back + to the completed-files listing. + """ + usable: List[Dict[str, str]] = [] + for channel in channels: + if not isinstance(channel, dict): + continue + fs_id = _lookup_channel_field(channel, _CHANNEL_FS_ID_KEYS) + src_dir = _lookup_channel_field(channel, _CHANNEL_SRC_DIR_KEYS) + if not fs_id or not src_dir: + continue + usable.append({"fs_id": fs_id, "src_dir": src_dir}) + if kds_id and _channel_references_kb_id(channel, kds_id): + return usable[-1] + + return usable[0] if usable else None + + +def list_aidp_channels_impl( + server_url: str, + api_key: str, + kds_id: str, + tenant_id: str | None = None, +) -> Dict[str, Any]: + """List the ingestion pipelines feeding one knowledge base via AIDP API. + + Endpoint: ``GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{kds_id}/Channels`` + Response: ``{"value": [{"fs_id": ..., "src_dir": ...}, ...]}`` + """ + normalized_url = _validate_params(server_url, api_key) + if not isinstance(kds_id, str) or not kds_id.strip(): + raise AppException( + ErrorCode.AIDP_CONFIG_INVALID, + "AIDP channel listing requires a non-empty knowledge base id", + ) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + channels_path = _get_channels_path(kds_id, tenant_id) + channels_url = urljoin(f"{normalized_url}/", channels_path) + logger.info("Listing AIDP channels from %s", channels_url) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.get(channels_url, headers=headers), + context="list-channels", + ) + response.raise_for_status() + result = response.json() + items = _extract_list_payload(result) + if items is None: + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP channel list response format", + ) + # Downstream readers always read ``value``, so the extracted items are + # re-emitted under that canonical key whichever envelope this build used. + payload = result if isinstance(result, dict) else {} + payload["value"] = [item for item in items if isinstance(item, dict)] + return payload + except httpx.RequestError as e: + logger.exception("AIDP request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + logger.exception( + "AIDP API HTTP error: %s, status_code: %s", + e, + e.response.status_code, + ) + if e.response.status_code in (401, 403): + raise AppException( + ErrorCode.AIDP_AUTH_ERROR, + f"AIDP authentication failed: {str(e)}", + ) + if e.response.status_code == 429: + raise AppException( + ErrorCode.AIDP_RATE_LIMIT, + f"AIDP rate limit exceeded: {str(e)}", + ) + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + f"AIDP API HTTP error {e.response.status_code}: {str(e)}", + ) + except ValueError as e: + logger.exception("Failed to parse AIDP API response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +def list_aidp_doc_history_impl( + server_url: str, + api_key: str, + fs_id: str, + dir_path: str, + kds_id: str, + tenant_id: str | None = None, +) -> Dict[str, Any]: + """List every file in a channel directory regardless of processing status. + + Endpoint: ``POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{kds_id}/KnowledgeFiles/History`` + Body: ``{"fs_id": , "dir_path": }`` + Response: ``{"value": [, ...]}`` + + Unlike ``list_aidp_docs_impl`` this returns files that are still being + chunked/embedded (``PROCESSING``) or that failed (``FAILED``), which is what + lets the UI show an upload immediately instead of only after ingestion. + """ + normalized_url = _validate_params(server_url, api_key) + + if not isinstance(kds_id, str) or not kds_id.strip(): + raise AppException( + ErrorCode.AIDP_CONFIG_INVALID, + "AIDP file history requires a non-empty knowledge base id", + ) + normalized_fs_id = fs_id.strip() if isinstance(fs_id, str) else "" + normalized_dir_path = dir_path.strip() if isinstance(dir_path, str) else "" + if not normalized_fs_id or not normalized_dir_path: + raise AppException( + ErrorCode.AIDP_CONFIG_INVALID, + "AIDP file history requires a non-empty fs_id and dir_path", + ) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + history_path = _get_doc_history_path(kds_id, tenant_id) + history_url = urljoin(f"{normalized_url}/", history_path) + logger.info( + "Listing AIDP knowledge-file history from %s (fs_id=%s, dir_path=%s)", + history_url, + normalized_fs_id, + normalized_dir_path, + ) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + response = _request_with_retry( + lambda: client.post( + history_url, + headers=headers, + json={"fs_id": normalized_fs_id, "dir_path": normalized_dir_path}, + ), + context=f"list-doc-history:{normalized_fs_id}", + ) + response.raise_for_status() + result = response.json() + items = _extract_list_payload(result) + if items is None: + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP file history response format", + ) + normalized_items = [ + _normalize_history_doc(item) if isinstance(item, dict) else item + for item in items + ] + _warn_when_history_status_unreadable(normalized_items, normalized_fs_id) + payload = result if isinstance(result, dict) else {} + payload["value"] = normalized_items + return payload + except httpx.RequestError as e: + logger.exception("AIDP request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + logger.exception( + "AIDP API HTTP error: %s, status_code: %s", + e, + e.response.status_code, + ) + if e.response.status_code in (401, 403): + raise AppException( + ErrorCode.AIDP_AUTH_ERROR, + f"AIDP authentication failed: {str(e)}", + ) + if e.response.status_code == 429: + raise AppException( + ErrorCode.AIDP_RATE_LIMIT, + f"AIDP rate limit exceeded: {str(e)}", + ) + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + f"AIDP API HTTP error {e.response.status_code}: {str(e)}", + ) + except ValueError as e: + logger.exception("Failed to parse AIDP API response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + # AIDP ModelService endpoint for listing applicable models. def _get_models_path(tenant_id: str | None = None) -> str: """Build the tenant-scoped model service API path.""" diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 6d7fdd48f5..9cbe6b7fb7 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -101,7 +101,10 @@ from utils.agent_stream_utils import ( enrich_file_uploads_with_presigned_urls as _enrich_file_uploads_with_presigned_urls, extract_json_objects_from_text as _extract_json_objects_from_text, + finalize_buffered_unit_fragments as _finalize_buffered_unit_fragments, + is_stream_unit_continuation as _is_continuation, process_skill_file_uploads as _process_skill_file_uploads, + rollback_model_attempt_units as _rollback_model_attempt_units, safe_agent_stream_error_chunk as _safe_agent_stream_error_chunk, serialize_stream_unit_content as _serialize_stream_unit_content, transform_skill_files_to_standard_format as _transform_skill_files_to_standard_format, @@ -129,19 +132,6 @@ _fa_extraction_tasks: set[asyncio.Task[None]] = set() -def _finalize_buffered_unit_fragments(message_units: list[dict[str, Any]]) -> int: - """Join mergeable unit fragments once and return finalized UTF-8 bytes.""" - finalized_bytes = 0 - for unit in message_units: - fragments = unit.pop("_content_fragments", None) - if fragments is not None: - content = "".join(fragments) - unit["content"] = content - unit["unit_content"] = content - finalized_bytes += len(str(unit.get("unit_content", "")).encode("utf-8")) - return finalized_bytes - - def _unregister_agent_run_after_execution( conversation_id: int | str, user_id: str, @@ -511,17 +501,11 @@ async def _iter_run_chunks(): chunk_type = data.get("type") chunk_content = data.get("content", "") or "" - # Add unit_index to the chunk data for frontend resume skip logic. - # This allows frontend to accurately skip chunks that were already persisted. - # For mergeable types (continuing chunks), use the current unit's index. - # For new units, use the next_unit_index that will be assigned. + # Use the current unit index for continuations and the next index + # for new units so the frontend can skip persisted resume chunks. if streaming_message_id is not None and chunk_type: mergeable = chunk_type in _MERGEABLE_TYPES - if ( - current_unit is not None - and mergeable - and current_unit.get("type") == chunk_type - ): + if _is_continuation(current_unit, mergeable, chunk_type, data): # Continuing chunk - use current unit's index data["unit_index"] = current_unit["unit_index"] elif chunk_type not in ("search_content_placeholder",): @@ -540,6 +524,16 @@ async def _iter_run_chunks(): yield f"data: {chunk}\n\n" continue + if chunk_type == "model_attempt_control": + phase = data.get("phase") + attempt_id = data.get("attempt_id") + current_unit = None + if phase == "rollback" and isinstance(attempt_id, str): + _rollback_model_attempt_units(buffered_units, attempt_id) + await channel.publish(f"data: {chunk}\n\n") + yield f"data: {chunk}\n\n" + continue + if chunk_type == ProcessType.SKILL_ARTIFACT.value: artifact_content = data.get("content") if isinstance(artifact_content, str): @@ -633,10 +627,8 @@ async def _iter_run_chunks(): # stream reaches a terminal state. if streaming_message_id is not None and chunk_type: mergeable = chunk_type in _MERGEABLE_TYPES - is_continuation = ( - current_unit is not None - and mergeable - and current_unit.get("type") == chunk_type + is_continuation = _is_continuation( + current_unit, mergeable, chunk_type, data ) if is_continuation: @@ -763,6 +755,7 @@ async def _iter_run_chunks(): "unit_content": persisted_content, "tool_call_id": data.get("tool_call_id"), "invocation_id": data.get("invocation_id"), + "_attempt_id": data.get("attempt_id"), "mergeable": mergeable, } if mergeable: @@ -815,9 +808,17 @@ async def _iter_run_chunks(): else "failed" ) outcome = getattr(agent_run_info, "attempt_outcome", None) - if getattr(agent_run_info, "human_interaction", None) is not None and isinstance(outcome, str): + if ( + getattr(agent_run_info, "human_interaction", None) is not None + and isinstance(outcome, str) + ): terminal_status = outcome if stream_completed_normally else "recovery_required" agent_run_info.attempt_outcome = terminal_status + elif outcome in {"failed", "stopped"}: + # A typed terminal model error is delivered as a normal observer + # ``error`` chunk, so the async iterator can finish normally while + # the worker outcome still authoritatively marks the run failed. + terminal_status = outcome try: skill_file_payloads = list(captured_skill_files.values()) diff --git a/backend/services/agent_evaluation_service.py b/backend/services/agent_evaluation_service.py index d32a1fb78c..d1b55b72f3 100644 --- a/backend/services/agent_evaluation_service.py +++ b/backend/services/agent_evaluation_service.py @@ -62,7 +62,10 @@ from database.evaluator_db import get_evaluator from management.services.agent.service import prepare_agent_run from services.evaluation_set_service import resolve_latest_published_version_no -from services.thread_lifecycle_service import runtime_thread_manager +from services.thread_lifecycle_service import ( + config_thread_manager, + runtime_thread_manager, +) from utils.llm_utils import call_llm_for_system_prompt from utils.prompt_template_utils import get_prompt_template @@ -1054,12 +1057,12 @@ def _check_run_limits(tenant_id: str) -> None: def _run_in_background( fn, *fn_args, tenant_id, user_id, agent_evaluation_id, language="zh" ): - """Submit fn to the Runtime evaluation lane and attach failure cleanup.""" - execution = runtime_thread_manager.submit( + """Submit Config-owned preparation or dispatch work in the background.""" + execution = config_thread_manager.submit( "evaluation", ManagedTaskSpec( task_name="agent-evaluation-run", - owner="runtime", + owner="config", run_id=str(agent_evaluation_id), ), fn, diff --git a/backend/services/human_interaction/application.py b/backend/services/human_interaction/application.py index 05ea8bbd83..d2251fa322 100644 --- a/backend/services/human_interaction/application.py +++ b/backend/services/human_interaction/application.py @@ -3,6 +3,7 @@ import asyncio import json import time +from contextlib import suppress from functools import lru_cache from fastapi.responses import StreamingResponse @@ -162,25 +163,83 @@ def authorize(): # Deployments opt into the conservative approval gate independently. port.allowed_tools = _allowed_tool_names(config.tools) run_info.human_interaction = runtime_type(port) - buffered_chunks = [] + + _FLUSH_INTERVAL = 0.05 + _FLUSH_BATCH = 16 + last_flush = time.monotonic() - async for chunk in _stream_agent_chunks( + + async def _flush_if_due() -> None: + """Flush buffered chunks on the interval/batch trigger to keep DB order. + + The async loop flushes on its own; the worker flushes again before + each HITL transaction. Peek first and never take-put-back — that + would open a transiently empty window the worker's idle poll can + mistake for "async is done". + """ + nonlocal last_flush + if (time.monotonic() - last_flush >= _FLUSH_INTERVAL + or port.peek_chunks() >= _FLUSH_BATCH): + buffered = port.take_chunks() + if buffered: + try: + port.begin_emit() + await run_blocking( + "hitl-port-emit_chunks", port.emit_chunks, buffered, + lane="control-io", owner=__name__, + ) + last_flush = time.monotonic() + finally: + port.end_emit() + + chunk_iter = _stream_agent_chunks( agent_request=request, user_id=identity["user_id"], tenant_id=identity["tenant_id"], agent_run_info=run_info, memory_ctx=memory_context, - ): - buffered_chunks.append(chunk) - if len(buffered_chunks) >= 32 or time.monotonic() - last_flush >= 0.25: - await run_blocking( - "hitl-port-emit_chunks", port.emit_chunks, buffered_chunks, lane="control-io", - owner=__name__, + ).__aiter__() + + anext_task: asyncio.Task | None = None + try: + anext_task = asyncio.create_task(chunk_iter.__anext__()) + while True: + # On timeout the task stays pending; the worker may resume it later. + done, pending = await asyncio.wait( + {anext_task}, + timeout=_FLUSH_INTERVAL, + return_when=asyncio.FIRST_COMPLETED, ) - buffered_chunks = [] - last_flush = time.monotonic() - if buffered_chunks: - await run_blocking( - "hitl-port-emit_chunks", port.emit_chunks, buffered_chunks, lane="control-io", - owner=__name__, - ) + if pending: + # No new chunk yet; flush what we have to keep DB order. + await _flush_if_due() + continue + + task = done.pop() + try: + chunk = task.result() + except StopAsyncIteration: + break + port.add_chunk(chunk) + await _flush_if_due() + anext_task = asyncio.create_task(chunk_iter.__anext__()) + finally: + if anext_task is not None: + anext_task.cancel() + with suppress(asyncio.CancelledError): + await anext_task + try: + await chunk_iter.aclose() + except Exception: + pass + # Final flush so leftover chunks precede finish() in DB order. + leftover = port.take_chunks() + if leftover: + try: + port.begin_emit() + await run_blocking( + "hitl-port-emit_chunks", port.emit_chunks, leftover, + lane="control-io", owner=__name__, + ) + finally: + port.end_emit() await run_blocking( "hitl-port-finish", port.finish, run_info.attempt_outcome or "failed", lane="control-io", owner=__name__, @@ -264,7 +323,7 @@ async def release(self, job_id, owner_id): async def stream_run(run_id, tenant_id, user_id, *, after=0): service = require_enabled() snapshot = await run_blocking( - "hitl-service-snapshot", service.snapshot, run_id, tenant_id, user_id, lane="control-io", + "hitl-service-snapshot", service.light_snapshot, run_id, tenant_id, user_id, lane="control-io", owner=__name__, ) if after > snapshot["event_seq"]: @@ -276,7 +335,7 @@ async def events(): while True: # Re-check ownership and deadlines for reconnecting subscribers. current = await run_blocking( - "hitl-service-snapshot", service.snapshot, run_id, tenant_id, user_id, lane="control-io", + "hitl-service-snapshot", service.light_snapshot, run_id, tenant_id, user_id, lane="control-io", owner=__name__, ) rows = await run_blocking( diff --git a/backend/services/human_interaction/runtime_port.py b/backend/services/human_interaction/runtime_port.py index 373e065aa0..62b01b417e 100644 --- a/backend/services/human_interaction/runtime_port.py +++ b/backend/services/human_interaction/runtime_port.py @@ -1,5 +1,6 @@ """Fenced tool dispatch adapter. Approval and STARTED are consumed under one run lock.""" +import threading import time from contextlib import contextmanager from datetime import timezone @@ -17,6 +18,14 @@ class RuntimeInteractionPort: def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, live_resume=False, stop_event=None): + """Bind the port and set up async-worker chunk coordination. + + ``_chunk_buffer`` stages processed observer chunks: the async consumer + appends via add_chunk and the worker flushes before each HITL event so + chunk rows precede human_interaction rows in DB event order. + ``_emit_in_flight`` marks an in-flight hand-off to the DB thread so an + empty buffer is not mistaken for "async is idle". + """ self.service = service self.repository = service.repository self.cipher = service.cipher @@ -29,10 +38,73 @@ def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, self.allowed_tools = frozenset(allowed_tools) self.live_resume = live_resume self.stop_event = stop_event + self._chunk_buffer: list[str] = [] + self._chunk_buffer_lock = threading.Lock() + self._emit_in_flight = threading.Event() with self.transaction() as tx: self.checkpoint = self.cipher.open(tx.run.checkpoint) self.request_payload = self.cipher.open(tx.run.request_payload) + def add_chunk(self, chunk: str) -> None: + """Append a processed chunk from the async consumer to the shared buffer.""" + with self._chunk_buffer_lock: + self._chunk_buffer.append(chunk) + + def take_chunks(self) -> list[str]: + """Atomically drain the shared buffer for persistence.""" + with self._chunk_buffer_lock: + chunks = self._chunk_buffer + self._chunk_buffer = [] + return chunks + + def peek_chunks(self) -> int: + """Return the number of buffered chunks without draining them.""" + with self._chunk_buffer_lock: + return len(self._chunk_buffer) + + def begin_emit(self) -> None: + """Signal that the async consumer is handing chunks to run_blocking(emit_chunks).""" + self._emit_in_flight.set() + + def end_emit(self) -> None: + """Signal that the async consumer's run_blocking(emit_chunks) has returned.""" + self._emit_in_flight.clear() + + def flush_chunks_until_idle(self, *, max_wait_ms: int = 500, settle_ms: int = 20) -> None: + """Wait for the async consumer to drain the observer queue, then persist. + + Worker-thread only. Returns once the buffer has stayed empty for + ``settle_ms`` with no in-flight emit; ``max_wait_ms`` is a hard + upper bound measured from entry and never resets. + """ + hard_deadline = time.monotonic() + max_wait_ms / 1000.0 + idle_since: float | None = None + + while True: + now = time.monotonic() + if now >= hard_deadline: + break + + chunks = self.take_chunks() + if chunks: + try: + self.emit_chunks(chunks) + except Exception: + # Never lose drained chunks; put them back for retry. + for chunk in chunks: + self.add_chunk(chunk) + raise + idle_since = None + elif self._emit_in_flight.is_set(): + # Empty buffer is not idle while an emit is in flight. + idle_since = None + elif idle_since is None: + idle_since = now + elif now - idle_since >= settle_ms / 1000.0: + break + + time.sleep(min(settle_ms / 1000.0, hard_deadline - now)) + @contextmanager def transaction(self, *, receipt=False): with self.repository.transaction(self.run_id, self.tenant_id, self.user_id) as tx: @@ -75,6 +147,8 @@ def boundary(self, checkpoint): self.authorize() suspended = False feedback = None + # Flush so buffered chunks precede the human_run status row in DB order. + self.flush_chunks_until_idle() with self.transaction() as tx: tx.run.checkpoint = self.cipher.seal(checkpoint) if tx.run.pause_requested: @@ -139,6 +213,8 @@ def _wait_until_ready(self): raise RunTerminated("Execution lease is no longer valid") self.service._expire(tx) if tx.run.status == "READY": + # Flush so lingering chunks precede the human_run row. + self.flush_chunks_until_idle() tx.run.status = "RUNNING" tx.emit({"type": "human_run", "content": { "run_id": self.run_id, "status": "RUNNING", @@ -156,6 +232,9 @@ def dispatch(self, slot, tool, arguments, *, interaction=None): suspended = False steering_requested = False outcome = None + # Flush before the transaction so chunk rows get lower seq numbers + # than any subsequent human_interaction row. + self.flush_chunks_until_idle() with self.transaction() as tx: if tx.run.pause_requested: self.service._request_steering(tx) @@ -234,6 +313,8 @@ def dispatch(self, slot, tool, arguments, *, interaction=None): return outcome def receipt(self, slot, result, *, uncertain=False): + # Flush so the DB event order is chunks → human_execution. + self.flush_chunks_until_idle() with self.transaction(receipt=True) as tx: execution = tx.execution(slot) if execution is None or execution.status != "STARTED": @@ -274,6 +355,14 @@ def visible_guidance(self): ] def finish(self, outcome): + """Write the terminal run status, flushing buffered chunks first. + + A flush failure must not prevent the terminal status row. + """ + try: + self.flush_chunks_until_idle() + except Exception: + pass with self.transaction(receipt=True) as tx: if tx.run.status in {"STOPPED", "EXPIRED"}: return diff --git a/backend/services/human_interaction/service.py b/backend/services/human_interaction/service.py index 5a2453d40a..4e5f1caba2 100644 --- a/backend/services/human_interaction/service.py +++ b/backend/services/human_interaction/service.py @@ -96,13 +96,29 @@ def _expire(self, tx): return expired def snapshot(self, run_id, tenant_id, user_id): + """Writer-path snapshot: takes a lock and may expire stale requests. + + Kept for the SSE stream's initial handshake where a terminal-status + request needs a lock-held check. Prefer :meth:`light_snapshot` for + read-only callers. + """ with self.repository.transaction(run_id, tenant_id, user_id) as tx: run = self.require(tx) self._expire(tx) - return {"run_id": run.run_id, "conversation_id": run.conversation_id, "status": run.status, - "event_seq": run.event_seq, "pause_requested": bool(run.pause_requested), - "attempt_active": bool(run.lock_until and run.lock_until > utcnow()), - "requests": [self._project_request(item, run.run_id) for item in tx.requests() if item.status == "PENDING"]} + return self._build_snapshot(run, tx) + + def light_snapshot(self, run_id, tenant_id, user_id): + """Read-only snapshot: no lock, no writes, no expiration scan.""" + with self.repository.read_only(run_id, tenant_id, user_id) as tx: + if tx is None: + raise InteractionError("Human interaction run was not found", 404) + return self._build_snapshot(tx.run, tx) + + def _build_snapshot(self, run, tx): + return {"run_id": run.run_id, "conversation_id": run.conversation_id, "status": run.status, + "event_seq": run.event_seq, "pause_requested": bool(run.pause_requested), + "attempt_active": bool(run.lock_until and run.lock_until > utcnow()), + "requests": [self._project_request(item, run.run_id) for item in tx.requests() if item.status == "PENDING"]} def request(self, tx, *, kind, slot, action_digest, payload): request = HumanRequest( diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 5928303c6f..ff0b871a08 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -19,6 +19,7 @@ DASHSCOPE_BASE_URL, DASHSCOPE_REALTIME_BASE_URL, TOKENPONY_BASE_URL, + MODEL_ENGINE_URL_MARKER, ) from database.model_management_db import ( @@ -340,16 +341,16 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict ) # Auto-set ssl_verify based on api_key: # - Empty api_key (local/LAN services) -> ssl_verify=False - # - "open/router" URL -> ssl_verify=False + # - ModelEngine URL (self-signed certs) -> ssl_verify=False # - Otherwise -> ssl_verify=True model_api_key = model_data.get("api_key", "") - if not model_api_key or "open/router" in model_base_url: + if not model_api_key or MODEL_ENGINE_URL_MARKER in model_base_url: model_data["ssl_verify"] = False else: model_data["ssl_verify"] = True - # Set model_factory to modelengine when using open/router URL - if "open/router" in model_base_url: + # Set model_factory to modelengine when using a ModelEngine URL + if MODEL_ENGINE_URL_MARKER in model_base_url: model_data["model_factory"] = "modelengine" if model_data.get("model_type") in ("vlm", "vlm2", "vlm3", "vlm4"): @@ -784,9 +785,21 @@ async def update_single_model_for_tenant( # Auto-set ssl_verify based on api_key if provided: # - Empty api_key -> ssl_verify=False + # - ModelEngine URL (self-signed certs) -> ssl_verify=False # - Otherwise -> ssl_verify=True + # The open/router exemption mirrors the create path + # (create_model_for_tenant): without it, editing a ModelEngine model + # submits the prefilled non-empty api_key and silently flips + # ssl_verify to True, breaking connectivity against its self-signed + # certificate. The URL is taken from the update payload when present + # and falls back to the stored record otherwise. if "api_key" in model_data: - if not model_data["api_key"]: + effective_base_url = ( + model_data.get("base_url") + or (existing_models[0].get("base_url") if existing_models else "") + or "" + ) + if not model_data["api_key"] or MODEL_ENGINE_URL_MARKER in effective_base_url: model_data["ssl_verify"] = False else: model_data["ssl_verify"] = True diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 828b209fa0..11e9424a7a 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -29,8 +29,14 @@ async def get_provider_models(model_data: dict) -> List[dict]: """ Get model list based on provider. - All providers are queried via the standard OpenAI-compatible - GET {base_url}/models endpoint. + ModelEngine is dispatched to its dedicated provider class: its models + endpoint lives at {host}/open/router/v1/models (not the OpenAI + /models path), the response uses ModelEngine's own type taxonomy + ("chat"/"embed"/"multimodal"/...) that must be mapped to internal + types, each model must carry its host so prepare_model_dict can build + the full base_url later, and its endpoints serve self-signed + certificates (ssl=False). All other providers are queried via the + standard OpenAI-compatible GET {base_url}/models endpoint. Args: model_data: Model data containing provider information @@ -38,10 +44,12 @@ async def get_provider_models(model_data: dict) -> List[dict]: Returns: List of models from the specified provider """ - provider = OpenAICompatibleProvider() - model_list = await provider.get_models(model_data) - - return model_list + provider_key = (model_data.get("provider") or "").lower() + if provider_key == ProviderEnum.MODELENGINE.value: + client: AbstractModelProvider = ModelEngineProvider() + else: + client = OpenAICompatibleProvider() + return await client.get_models(model_data) # ============================================================================= diff --git a/backend/services/runtime_proxy_service.py b/backend/services/runtime_proxy_service.py index 4a5b1d90d1..cde7e8dae7 100644 --- a/backend/services/runtime_proxy_service.py +++ b/backend/services/runtime_proxy_service.py @@ -29,6 +29,7 @@ _STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0) _REQUEST_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0) _EVALUATION_DISPATCH_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0) +_EVALUATION_TRIAL_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0) _RUNTIME_SERVICE_UNAVAILABLE_MESSAGE = "Runtime service is unavailable" @@ -101,6 +102,58 @@ def dispatch_agent_evaluation_run( return payload +async def forward_agent_evaluation_trial_run( + *, + agent_id: int, + agent_version_no: int, + query: str, + judge_model_id: int, + evaluator_ids: list[int] | None, + language: str, + user_id: str, + tenant_id: str, +) -> dict: + """Execute an ad-hoc evaluation in the Runtime process and return its result.""" + try: + async with create_httpx_client( + headers=_authorization_headers(user_id, tenant_id), + timeout=_EVALUATION_TRIAL_TIMEOUT, + ) as client: + response = await client.post( + _runtime_url("/agent-evaluations/internal/trial-run"), + json={ + "agent_id": agent_id, + "agent_version_no": agent_version_no, + "query": query, + "judge_model_id": judge_model_id, + "evaluator_ids": evaluator_ids, + "language": language, + }, + ) + except httpx.TimeoutException as exc: + raise RuntimeServiceTimeoutError("Runtime trial evaluation timed out") from exc + except httpx.RequestError as exc: + raise RuntimeServiceUnavailableError(_RUNTIME_SERVICE_UNAVAILABLE_MESSAGE) from exc + + if response.status_code >= 400: + raise RuntimeUpstreamError( + status_code=response.status_code, + content=response.content, + headers=_forwarded_headers(response.headers), + ) + try: + payload = response.json() + except ValueError as exc: + raise RuntimeServiceUnavailableError( + "Runtime trial evaluation response is not valid JSON" + ) from exc + if not isinstance(payload, dict): + raise RuntimeServiceUnavailableError( + "Runtime trial evaluation response is not a JSON object" + ) + return payload + + async def forward_agent_run( agent_request: AgentRequest, user_id: str, diff --git a/backend/utils/agent_stream_utils.py b/backend/utils/agent_stream_utils.py index 728939fc9a..7e0a07483f 100644 --- a/backend/utils/agent_stream_utils.py +++ b/backend/utils/agent_stream_utils.py @@ -12,6 +12,47 @@ logger = logging.getLogger(__name__) +def finalize_buffered_unit_fragments(message_units: list[dict[str, Any]]) -> int: + """Join mergeable unit fragments once and return finalized UTF-8 bytes.""" + finalized_bytes = 0 + for unit in message_units: + unit.pop("_attempt_id", None) + fragments = unit.pop("_content_fragments", None) + if fragments is not None: + content = "".join(fragments) + unit["content"] = content + unit["unit_content"] = content + finalized_bytes += len(str(unit.get("unit_content", "")).encode("utf-8")) + return finalized_bytes + + +def rollback_model_attempt_units( + message_units: list[dict[str, Any]], attempt_id: str +) -> int: + """Remove uncommitted model fragments for one physical model attempt.""" + original_count = len(message_units) + message_units[:] = [ + unit for unit in message_units if unit.get("_attempt_id") != attempt_id + ] + return original_count - len(message_units) + + +def is_stream_unit_continuation( + current_unit: dict[str, Any] | None, + mergeable: bool, + chunk_type: str, + data: dict[str, Any], +) -> bool: + """Return whether a chunk can extend the current persisted stream unit.""" + return bool( + current_unit is not None + and mergeable + and current_unit.get("type") == chunk_type + and current_unit.get("_attempt_id") == data.get("attempt_id") + and current_unit.get("invocation_id") == data.get("invocation_id") + ) + + def extract_json_objects_from_text(text: str) -> list[dict]: """Extract all JSON objects embedded in a text blob.""" if not text: diff --git a/deploy/common/start-backend.sh b/deploy/common/start-backend.sh index a49d77661c..0c2e4d5aa4 100755 --- a/deploy/common/start-backend.sh +++ b/deploy/common/start-backend.sh @@ -2,6 +2,13 @@ set -euo pipefail +requested_umask="${UMASK:-0022}" +if [[ ! "$requested_umask" =~ ^0?[0-7]{3}$ ]]; then + printf '[start-backend] ERROR: unsupported UMASK: %s\n' "$requested_umask" >&2 + exit 1 +fi +umask "$requested_umask" + SQL_STARTUP_MODE="${NEXENT_SQL_STARTUP_MODE:-off}" if [ -z "${NEXENT_SQL_STARTUP_MODE+x}" ] && [ -n "${NEXENT_RUN_SQL_MIGRATIONS:-}" ]; then diff --git a/deploy/docker/compose/docker-compose.prod.yml b/deploy/docker/compose/docker-compose.prod.yml index 7a20d6a2d9..9bc9ceb0ce 100644 --- a/deploy/docker/compose/docker-compose.prod.yml +++ b/deploy/docker/compose/docker-compose.prod.yml @@ -85,7 +85,7 @@ services: NEXENT_SQL_STARTUP_MODE: migrate NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env - ../../env/monitoring.env @@ -120,7 +120,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env - ../../env/monitoring.env @@ -152,7 +152,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env user: root @@ -184,7 +184,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env user: root diff --git a/deploy/docker/compose/docker-compose.yml b/deploy/docker/compose/docker-compose.yml index 93dbfd10a6..1ee521ad66 100644 --- a/deploy/docker/compose/docker-compose.yml +++ b/deploy/docker/compose/docker-compose.yml @@ -98,7 +98,7 @@ services: NEXENT_SQL_STARTUP_MODE: migrate NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env - ../../env/monitoring.env @@ -135,7 +135,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env - ../../env/monitoring.env @@ -170,7 +170,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env user: root @@ -203,7 +203,7 @@ services: NEXENT_SQL_STARTUP_MODE: wait NEXENT_SQL_FILES_CHECKSUM: ${NEXENT_SQL_FILES_CHECKSUM:-} skip_proxy: "true" - UMASK: 0022 + UMASK: "0022" env_file: - ../../env/.env user: root diff --git a/frontend/app/[locale]/agents/components/agent-prompt.tsx b/frontend/app/[locale]/agents/components/agent-prompt.tsx index f991978274..4bc69effd1 100644 --- a/frontend/app/[locale]/agents/components/agent-prompt.tsx +++ b/frontend/app/[locale]/agents/components/agent-prompt.tsx @@ -28,6 +28,8 @@ import { ModelAdvancedSettingsValue, advancedSettingsValueFromRecord, buildModelOverrideEntry, + diffCustomParamsForSave, + mergeCustomParamsForEditing, } from "../../models/components/model/ModelAdvancedSettings"; import type { ModelOverrideMap } from "../../models/components/model/ModelOverrideModal"; import { canManageModels } from "@/lib/auth"; @@ -208,22 +210,20 @@ export default function AgentPrompt() { setEditingOverrideValue(null); return; } - const modelDefaults: Record = { - temperature: (configuringModel as any).temperature, - top_p: (configuringModel as any).topP, - extra_params: (configuringModel as any).extraParams, - }; + // Only override values are editable in this dialog; model-level defaults + // show up as placeholders via inheritedDefaults. Merging model defaults + // into the editing state made "deleted" overrides reappear (the model + // value was being re-merged as if the user had set it in the override). const overrideEntry = modelParamsOverride[String(configuringModel.id)] ?? {}; - const formRecord: Record = { ...modelDefaults, ...overrideEntry }; - if (overrideEntry.extra_params && modelDefaults.extra_params) { - formRecord.extra_params = { - ...(modelDefaults.extra_params as Record), - ...(overrideEntry.extra_params as Record), - }; - } - setEditingOverrideValue( - advancedSettingsValueFromRecord(formRecord as any, inferenceSpecs, (configuringModel as any).type ?? "llm") + const base = advancedSettingsValueFromRecord(overrideEntry as any, inferenceSpecs, (configuringModel as any).type ?? "llm"); + // Custom params are the exception: model-level customs render as plain + // editable rows (merged display). Deletion persists as a null marker in + // the override entry, so deleted rows do NOT reappear on reopen. + base.__custom__ = mergeCustomParamsForEditing( + (overrideEntry as any).extra_params?.__custom__, + (configuringModel as any).extraParams?.__custom__ ); + setEditingOverrideValue(base); // eslint-disable-next-line react-hooks/exhaustive-deps }, [configuringModelId]); @@ -544,11 +544,21 @@ export default function AgentPrompt() { ); const diffValue: ModelAdvancedSettingsValue = {}; for (const [key, val] of Object.entries(editingOverrideValue)) { + // Custom params are diffed per-key below (deleting an + // inherited row must persist a removal, not drop the key). + if (key === "__custom__") continue; const modelVal = modelDefaults[key]; if (JSON.stringify(modelVal) !== JSON.stringify(val)) { diffValue[key] = val; } } + const customDiff = diffCustomParamsForSave( + editingOverrideValue.__custom__, + (configuringModel as any).extraParams?.__custom__ + ); + if (Object.keys(customDiff).length > 0) { + diffValue.__custom__ = customDiff; + } handleModelParamsOverrideChange(configuringModel.id, diffValue); } setConfiguringModelId(null); diff --git a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx index 79df8b66b0..c0519e6e77 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx @@ -83,6 +83,8 @@ interface JsonData { last_unit_index?: number; replay_chunk_count?: number; conversation_id?: number; + attempt_id?: string; + phase?: "begin" | "rollback" | "commit"; } // Reconstruct streaming state from persisted units (for tab-switch recovery) @@ -460,6 +462,10 @@ export const handleStreamResponse = async ( let finalAnswer = ""; let lastModelOutputIndex = -1; let lastContentType: string | null = null; + const attemptBlockCheckpoints = new Map< + string, + { originalLengths: Map; createdIds: Set } + >(); if (resumeConfig) { const recovered = reconstructFromStreamingMessage( @@ -549,6 +555,42 @@ export const handleStreamResponse = async ( } } + if ( + jsonData.type === "model_attempt_control" && + jsonData.attempt_id && + jsonData.phase + ) { + if (jsonData.phase === "begin") { + attemptBlockCheckpoints.set(jsonData.attempt_id, { + originalLengths: new Map(), + createdIds: new Set(), + }); + } else if (jsonData.phase === "rollback") { + const checkpoints = attemptBlockCheckpoints.get( + jsonData.attempt_id + ); + currentStep.contents = currentStep.contents.filter((item) => { + if (checkpoints?.createdIds.has(item.id)) { + return false; + } + const originalLength = checkpoints?.originalLengths.get( + item.id + ); + if (originalLength !== undefined) { + item.content = item.content.slice(0, originalLength); + } + return true; + }); + attemptBlockCheckpoints.delete(jsonData.attempt_id); + lastModelOutputIndex = currentStep.contents.length - 1; + lastContentType = + currentStep.contents[lastModelOutputIndex]?.type ?? null; + } else { + attemptBlockCheckpoints.delete(jsonData.attempt_id); + } + continue; + } + if (jsonData.type && jsonData.content) { const messageType = jsonData.type; @@ -686,21 +728,40 @@ export const handleStreamResponse = async ( lastContentBlock && lastContentBlock.type === messageType; if (shouldAppend) { + if (jsonData.attempt_id) { + const checkpoints = attemptBlockCheckpoints.get( + jsonData.attempt_id + ); + if ( + !checkpoints?.originalLengths.has(lastContentBlock.id) + ) { + checkpoints?.originalLengths.set( + lastContentBlock.id, + lastContentBlock.content.length + ); + } + } // Same type - append to existing block lastContentBlock.content += messageContent; } else { // Different type or no existing block - create new content block // This ensures thinking and deep_thinking are shown as separate nodes + const blockId = `model-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; currentStep.contents.push({ - id: `model-${Date.now()}-${Math.random() - .toString(36) - .substring(2, 7)}`, + id: blockId, type: messageType, subType, content: messageContent, expanded: true, timestamp: Date.now(), }); + if (jsonData.attempt_id) { + attemptBlockCheckpoints + .get(jsonData.attempt_id) + ?.createdIds.add(blockId); + } lastModelOutputIndex = currentStep.contents.length - 1; } @@ -741,19 +802,37 @@ export const handleStreamResponse = async ( lastModelOutputIndex >= 0 && currentStep.contents[lastModelOutputIndex] ) { - currentStep.contents[lastModelOutputIndex].content += - processedContent; + const codeBlock = + currentStep.contents[lastModelOutputIndex]; + if (jsonData.attempt_id) { + const checkpoints = attemptBlockCheckpoints.get( + jsonData.attempt_id + ); + if (!checkpoints?.originalLengths.has(codeBlock.id)) { + checkpoints?.originalLengths.set( + codeBlock.id, + codeBlock.content.length + ); + } + } + codeBlock.content += processedContent; } else { // Create new main content block for code + const blockId = `model-code-${Date.now()}-${Math.random() + .toString(36) + .substring(2, 7)}`; currentStep.contents.push({ - id: `model-code-${Date.now()}-${Math.random() - .toString(36) - .substring(2, 7)}`, + id: blockId, type: chatConfig.messageTypes.MODEL_OUTPUT_CODE, content: processedContent, expanded: true, timestamp: Date.now(), }); + if (jsonData.attempt_id) { + attemptBlockCheckpoints + .get(jsonData.attempt_id) + ?.createdIds.add(blockId); + } lastModelOutputIndex = currentStep.contents.length - 1; } @@ -769,8 +848,9 @@ export const handleStreamResponse = async ( } // If it does not exist, add one + const blockId = `generating-code-${stepIdCounter.current}`; const newGeneratingItem = { - id: `generating-code-${stepIdCounter.current}`, + id: blockId, type: chatConfig.messageTypes.GENERATING_CODE, content: t("chatStreamHandler.callingTool"), expanded: true, @@ -779,6 +859,11 @@ export const handleStreamResponse = async ( }; currentStep.contents.push(newGeneratingItem); + if (jsonData.attempt_id) { + attemptBlockCheckpoints + .get(jsonData.attempt_id) + ?.createdIds.add(blockId); + } // Mark as code generation type lastContentType = chatConfig.contentTypes.GENERATING_CODE; diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialogV2.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialogV2.tsx index e8bb940158..c534cf925e 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialogV2.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialogV2.tsx @@ -906,6 +906,10 @@ export const ModelAddDialogV2 = ({ modelType: resolvedModelType, baseUrl: customForm.url, apiKey: customForm.apiKey, + // Edit mode leaves apiKey empty to "keep existing"; pass the model + // id so the backend probes with the stored key instead of the + // "sk-no-api-key" placeholder. + modelId: model?.id, ...capacityPayload, ...inferencePayload, ...embeddingPayload, @@ -1062,6 +1066,20 @@ export const ModelAddDialogV2 = ({ ctx.resolvedModelType === MODEL_TYPES.MULTI_EMBEDDING ? "multiEmbedding" : ctx.resolvedModelType; + // Never steal an occupied default slot: adding or editing a model is + // not an intent to change the tenant default. Persist only when the + // slot is empty (add: deterministic onboarding default, mirroring the + // backend backfill) or when the submitted model already occupies the + // slot (edit: keep the slot's apiKey/url in sync with the model's new + // values). + const currentSlotDisplayName = modelConfig?.[configKey]?.displayName; + const slotIsFree = !currentSlotDisplayName; + const submitsCurrentSlotModel = + !!model && + currentSlotDisplayName === (model.displayName || model.name); + if (!slotIsFree && !submitsCurrentSlotModel) { + return; + } const existingApiKey = modelConfig?.[configKey]?.apiConfig?.apiKey || ""; const nextModelConfig: SingleModelConfig = { id: 0, diff --git a/frontend/app/[locale]/models/components/model/ModelAdvancedSettings.tsx b/frontend/app/[locale]/models/components/model/ModelAdvancedSettings.tsx index 8c0d31b237..dbbc03c4d7 100644 --- a/frontend/app/[locale]/models/components/model/ModelAdvancedSettings.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAdvancedSettings.tsx @@ -200,8 +200,13 @@ const formatCustomValueForEditing = (raw: unknown): string => { /** Convert the editing-state __custom__ entries array into a clean wire dict. * Empty keys are dropped and duplicates collapse (last-wins). + * A plain object passes through as-is: the override save path builds the + * final dict directly and uses null values as explicit removal markers. */ const buildCustomDict = (raw: unknown): Record => { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + return { ...(raw as Record) }; + } const entries = Array.isArray(raw) ? (raw as [string, string][]) : []; const dict: Record = {}; for (const [k, v] of entries) { @@ -213,6 +218,73 @@ const buildCustomDict = (raw: unknown): Record => { return dict; }; +/** + * Merge model-level and override custom params into the editing entries + * shown in the override dialog. Model-level params render as plain editable + * rows (pre-2.6.0 merged display); override values win; null override values + * are explicit removal markers and hide the key entirely. + */ +export const mergeCustomParamsForEditing = ( + overrideCustoms: Record | null | undefined, + modelCustoms: Record | null | undefined +): [string, string][] => { + const merged: Record = {}; + for (const [k, v] of Object.entries(modelCustoms ?? {})) { + merged[k] = v; + } + for (const [k, v] of Object.entries(overrideCustoms ?? {})) { + if (v === null || v === undefined) { + delete merged[k]; + } else { + merged[k] = v; + } + } + return Object.entries(merged).map( + ([k, v]) => [k, formatCustomValueForEditing(v)] as [string, string] + ); +}; + +/** + * Diff the editing entries against model-level custom params for saving. + * Returns the wire dict stored in the override entry: + * - explicit values for keys that differ from the model level + * - null for keys whose inherited row the user deleted (the runtime pops + * null-marked keys from the merged request params) + * - keys equal to the model level are dropped (pure inherit, no override) + */ +export const diffCustomParamsForSave = ( + editingEntries: unknown, + modelCustoms: Record | null | undefined +): Record => { + const entries = Array.isArray(editingEntries) + ? (editingEntries as [string, string][]) + : []; + const editingDict: Record = {}; + for (const [k, v] of entries) { + if (k === "") continue; + const parsed = parseCustomValue(String(v ?? "")); + if (parsed === undefined) continue; + editingDict[k] = parsed; + } + const modelDict = modelCustoms ?? {}; + const result: Record = {}; + const keys = new Set([...Object.keys(modelDict), ...Object.keys(editingDict)]); + for (const k of keys) { + if (k in editingDict) { + if ( + !(k in modelDict) || + JSON.stringify(editingDict[k]) !== JSON.stringify(modelDict[k]) + ) { + result[k] = editingDict[k]; + } + } else if (k in modelDict) { + // Inherited row deleted by the user: persist an explicit removal. + result[k] = null; + } + } + return result; +}; + export const buildInferenceParamsPayload = ( value: ModelAdvancedSettingsValue ): { diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index e40eead40c..fcc13ce29d 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -65,6 +65,9 @@ interface SseChunk { // frontend can route streaming content to the matching card even when // sibling sub-agents execute in parallel. invocation_id?: string; + attempt_id?: string; + phase?: "begin" | "rollback" | "commit"; + attempt?: number; path?: string; block_id?: string; origin_type?: string; @@ -202,9 +205,7 @@ export interface Nl2aResourceCandidate { } export type Nl2aInstallationFormKind = - | "SKILL_CONFIG" - | "MCP_REMOTE" - | "MCP_CONTAINER"; + "SKILL_CONFIG" | "MCP_REMOTE" | "MCP_CONTAINER"; export interface Nl2aResourceInstallationOption { option_id: string; @@ -1462,8 +1463,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const history = historyMessages.map((msg) => { const customMetadata = isNl2Agent ? (msg.metadata?.custom as - | { nl2agentCardAction?: Nl2AgentCardAction } - | undefined) + { nl2agentCardAction?: Nl2AgentCardAction } | undefined) : undefined; const text = customMetadata?.nl2agentCardAction ? JSON.stringify(customMetadata.nl2agentCardAction) @@ -1563,8 +1563,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (abortHandled) return; abortHandled = true; const abortReason = abortSignal?.reason as - | { detach?: boolean } - | undefined; + { detach?: boolean } | undefined; if (abortReason?.detach) { log.log( `[ChatModelAdapter] Local stream detached from conversation ${backendConversationId ?? "unknown"}` @@ -1592,8 +1591,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { }; let agentResponse: - | ReadableStreamDefaultReader - | { type: "json"; data: unknown }; + ReadableStreamDefaultReader | { type: "json"; data: unknown }; let returnedRuntimeMetadataVersion: number | undefined; try { agentResponse = await conversationService.runAgent( @@ -1876,6 +1874,70 @@ export const remoteChatModelAdapter: ChatModelAdapter = { return resolved; }; + type SubAgentAttemptCheckpoint = { + invocationId: string; + reasoningIdx: number | null; + textLength: number; + }; + const subAgentAttemptCheckpoints = new Map< + string, + SubAgentAttemptCheckpoint + >(); + const removeContentPart = (index: number) => { + contentParts.splice(index, 1); + for (const slot of invocationSlots.values()) { + if (slot.reasoningIdx === index) slot.reasoningIdx = null; + else if (slot.reasoningIdx !== null && slot.reasoningIdx > index) { + slot.reasoningIdx -= 1; + } + } + }; + const handleModelAttemptControl = (chunk: SseChunk): boolean => { + if ( + chunk.type !== "model_attempt_control" || + !chunk.attempt_id || + !chunk.phase + ) { + return false; + } + const top = resolveSubAgent(chunk.invocation_id); + if (!top) { + if (chunk.phase === "begin") { + parentReasoning.beginAttempt(chunk.attempt_id); + } else if (chunk.phase === "rollback") { + parentReasoning.rollbackAttempt(chunk.attempt_id); + } else { + parentReasoning.commitAttempt(chunk.attempt_id); + } + return true; + } + + if (chunk.phase === "begin") { + const idx = top.slot.reasoningIdx; + subAgentAttemptCheckpoints.set(chunk.attempt_id, { + invocationId: top.invocationId, + reasoningIdx: idx, + textLength: idx === null ? 0 : (contentParts[idx]?.text?.length ?? 0), + }); + return true; + } + + const checkpoint = subAgentAttemptCheckpoints.get(chunk.attempt_id); + subAgentAttemptCheckpoints.delete(chunk.attempt_id); + if (chunk.phase !== "rollback" || !checkpoint) return true; + const slot = slotForInvocation(checkpoint.invocationId); + if (!slot || slot.reasoningIdx === null) return true; + if (checkpoint.reasoningIdx === null) { + removeContentPart(slot.reasoningIdx); + } else { + const part = contentParts[slot.reasoningIdx]; + if (part?.type === "reasoning") { + part.text = part.text.slice(0, checkpoint.textLength); + } + } + return true; + }; + const flushOpenReasoning = (specificInvocationId?: string | null) => { if (specificInvocationId) { const entry = activeSubAgents.get(specificInvocationId); @@ -2034,11 +2096,12 @@ export const remoteChatModelAdapter: ChatModelAdapter = { let firstTokenTime: number | undefined; let toolCallCount = 0; let storedTiming: ReturnType | null = null; + let hitlTerminal: boolean | undefined = undefined; try { while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done || hitlTerminal) break; buffer += decoder.decode(value, { stream: true }); @@ -2050,6 +2113,11 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const chunk = parseSseChunk(line); if (!chunk) continue; + if (handleModelAttemptControl(chunk)) { + yield buildStreamResult(contentParts); + continue; + } + if (chunk.type === "human_run") { const value = typeof chunk.content === "string" @@ -2058,6 +2126,16 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (value && typeof value.run_id === "string") humanRunId = value.run_id; custom?.onHumanInteractionEvent?.(); + // Terminal HITL status: stop reading so isRunning flips false + // without waiting for the backend to close the stream. + if ( + value && + typeof value.status === "string" && + ["COMPLETED", "FAILED", "STOPPED", "EXPIRED"].includes(value.status) + ) { + hitlTerminal = true; + break; + } continue; } if ( @@ -2654,7 +2732,9 @@ export const remoteChatModelAdapter: ChatModelAdapter = { flushOpenReasoning(); } const partType = - chunk.type === "step_count" ? "reasoning" : mapChunkType(chunk.type); + chunk.type === "step_count" + ? "reasoning" + : mapChunkType(chunk.type); if (chunk.type === "parse") { flushOpenReasoning(chunk.invocation_id); if (chunk.content.trim()) { diff --git a/frontend/const/knowledgeBase.ts b/frontend/const/knowledgeBase.ts index 0569f46606..e6a7bd853d 100644 --- a/frontend/const/knowledgeBase.ts +++ b/frontend/const/knowledgeBase.ts @@ -4,6 +4,11 @@ export const AIDP_KNOWLEDGE_BASE_NAME_PATTERN = /^[\u4e00-\u9fa5a-zA-Z][\u4e00-\u9fa5a-zA-Z0-9_]{0,255}$/; +// Debounce for the AIDP knowledge-base search box. Keystrokes are coalesced +// until the user pauses for this long, so typing "report" issues one request +// instead of six. +export const KB_SEARCH_DEBOUNCE_MS = 300; + // Document status constants export const DOCUMENT_STATUS = { WAIT_FOR_PROCESSING: "WAIT_FOR_PROCESSING", diff --git a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx b/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx index 5a14363ad5..5e4eb93ed1 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx @@ -1,69 +1,55 @@ "use client"; -import React, { useState, useMemo, useEffect, useRef } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; +import type { TFunction } from "i18next"; import { - Modal, + Collapse, Form, - Input, InputNumber, - Steps, - Upload, - Button, - message, + Modal, + Select, Space, - Divider, - Collapse, Switch, Tooltip, - Select, + Upload, + message, } from "antd"; -import { InboxOutlined, QuestionCircleOutlined } from "@ant-design/icons"; +import { + InboxOutlined, + QuestionCircleOutlined, + SettingOutlined, +} from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; -import type { AidpModelItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; +import type { + AidpModelItem, + AidpUploadResponse, +} from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; -import { USER_ROLES } from "@/const/auth"; - -/** - * Antd's Upload component (Dragger) requires ``originFileObj`` to satisfy - * the ``RcFile`` shape (``File`` + ``uid`` + ``lastModifiedDate``). We - * store raw ``File`` objects in component state, so we cast at the - * render boundary. The structural cast is sufficient because antd does - * not read the extra fields — it only requires them to exist for type - * compatibility. - */ -type RcFileLike = File & { uid: string; lastModifiedDate: Date }; -import { - AIDP_ACCEPT_STRING, - AIDP_KNOWLEDGE_BASE_NAME_PATTERN, -} from "@/const/knowledgeBase"; +import { AIDP_ACCEPT_STRING } from "@/const/knowledgeBase"; import { partitionAidpFiles, validateAidpFiles, } from "@/services/uploadService"; -import { useGroupList } from "@/hooks/group/useGroupList"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { getAidpUploadFailureDetails } from "@/ext_components/aidp/services/aidpUploadUtils"; +import { collectUploadedFileIds } from "@/lib/aidpDocumentStatus"; +import { useAidpGroupOptions } from "../hooks/useAidpGroupOptions"; +import { + AIDP_MODAL_STYLES, + AidpKnowledgeBaseBasicFields, + AidpKnowledgeBaseModalFooter, + AidpKnowledgeBaseModalHeader, + AidpKnowledgeBasePermissionFields, +} from "./AidpKnowledgeBaseModalParts"; const { Dragger } = Upload; -// Preferred VLM model name when present in AIDP's available list. -// Falls back to the first model in the list if this specific one is absent. const PREFERRED_VLM_MODEL = "Qwen3-VL-8B-Instruct"; +type AidpPermission = "EDIT" | "READ_ONLY" | "PRIVATE"; -/** - * Default AIDP knowledge base configuration. - * Aligned with sdk/nexent/core/knowledge_base/config.py (build_create_payload defaults). - * - * Required fields per AIDP schema: - * chunk_token_num (> 0), chunk_overlap_num (>= 0) - * Reference fills the rest (is_personal, topk, similarity, smartsplit, caption_enable). - * ``vlm_model`` is no longer a hardcoded constant — it is resolved at runtime - * from the list of models AIDP advertises as applicable to the KnowledgeBase - * application (see ``useQuery(["aidp-models"])``). - */ const AIDP_CREATE_DEFAULTS = { chunk_token_num: 1024, chunk_overlap_num: 128, @@ -72,15 +58,116 @@ const AIDP_CREATE_DEFAULTS = { topk: 10, similarity: 0.0, smartsplit: 1, - // caption_enable: int 0/1. caption_enable: 0, }; +const validateAidpCreateFiles = ( + files: File[], + t: TFunction, + notify: typeof message +) => { + if (files.length === 0) return true; + const validation = validateAidpFiles(files); + if (validation.valid.length === files.length) return true; + partitionAidpFiles(files, t, notify); + return false; +}; + +const getAidpCreatePermissionValues = ( + isUser: boolean, + values: { ingroup_permission?: string; group_ids?: unknown } +) => { + const configuredPermission = values.ingroup_permission; + let permission: AidpPermission = "READ_ONLY"; + if (isUser) { + permission = "PRIVATE"; + } else if ( + configuredPermission === "EDIT" || + configuredPermission === "READ_ONLY" || + configuredPermission === "PRIVATE" + ) { + permission = configuredPermission; + } + const groupIds = + isUser || permission === "PRIVATE" + ? [] + : Array.isArray(values.group_ids) + ? values.group_ids + : []; + return { permission, groupIds }; +}; + +const showAidpCreateUploadResult = ( + result: AidpUploadResponse, + language: string, + t: TFunction +) => { + const failureDetails = getAidpUploadFailureDetails( + result.failed_list, + language, + t("aidpKnowledge.uploadFailed") + ); + const failureLines = failureDetails.map((detail, index) => ( +
{detail}
+ )); + const allFailedContent = + failureLines.length > 0 ? ( + failureLines + ) : ( +
{t("aidpKnowledge.uploadFailed")}
+ ); + + if (result.summary.failed > 0 && result.summary.success === 0) { + message.warning( +
+
{t("aidpKnowledge.createKbSuccess")}
+ {allFailedContent} +
+ ); + return; + } + if (result.summary.failed > 0) { + message.info( +
+
{t("aidpKnowledge.createKbSuccess")}
+
+ {t("aidpKnowledge.uploadPartial", { + success: result.summary.success, + failed: result.summary.failed, + })} +
+ {failureLines} +
+ ); + return; + } + message.success( + `${t("aidpKnowledge.createKbSuccess")} | ${t( + "aidpKnowledge.uploadSuccess", + { count: result.summary.success } + )}` + ); +}; + +const getAidpCreateErrorReason = ( + error: unknown, + knowledgeBaseCreated: boolean, + t: TFunction +) => { + if (error instanceof Error && error.message.trim()) return error.message; + return knowledgeBaseCreated + ? t("aidpKnowledge.uploadFailed") + : t("aidpKnowledge.createKbFailed"); +}; + interface AidpCreateKbModalProps { open: boolean; existingKbs: AidpKnowledgeBaseItem[]; onCancel: () => void; - onSuccess: (knowledgeBase: AidpKnowledgeBaseItem) => void; + onSuccess: ( + knowledgeBase: AidpKnowledgeBaseItem, + uploadedFileIds?: string[] + ) => void; } const AidpCreateKbModal: React.FC = ({ @@ -91,140 +178,79 @@ const AidpCreateKbModal: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [form] = Form.useForm(); - const [current, setCurrent] = useState(0); const [loading, setLoading] = useState(false); const [fileList, setFileList] = useState([]); const fileListRef = useRef([]); + const pendingFilesRef = useRef([]); + const rafIdRef = useRef(null); useEffect(() => { fileListRef.current = fileList; }, [fileList]); - // Antd fires beforeUpload once per file in a multi-select batch. - // The `newFiles` array may-or-may-not be the same reference across the N - // calls (behavior differs between and and antd versions), - // so we cannot rely on reference-equality for a single-call-per-batch guard. - // Instead, we collect each file in beforeUpload and schedule a single - // requestAnimationFrame flush that runs validate/add once per batch. - // This guarantees partitionAidpFiles + toast execute exactly ONCE per - // user selection, regardless of antd's internal dispatch count. - const pendingFilesRef = useRef([]); - const rafIdRef = useRef(null); + const { isUser, canConfigureGroupPermissions, groupOptions } = + useAidpGroupOptions(); - // Load the tenant's groups so the user can pick which groups may access - // the new KB. When no tenant context is available we fall back to an - // empty list and disable the group picker. - // NOTE: ``useAuthorizationContext`` is required — it is the only context - // that exposes ``user: User | null`` (with ``tenantId``). The similarly- - // named ``useAuthenticationContext`` only carries ``session`` and the - // plain ``useAuthentication`` hook doesn't carry ``user`` at all. - const { user } = useAuthorizationContext(); - const isUser = user?.role === USER_ROLES.USER; - const canConfigureGroupPermissions = !!user && !isUser; - const tenantId = user?.tenantId ?? null; - const { data: groupListData } = useGroupList( - canConfigureGroupPermissions ? tenantId : null - ); - const groupOptions = useMemo( - () => - (groupListData?.groups ?? []).map((g) => ({ - value: g.group_id, - label: g.group_name, - })), - [groupListData] - ); - const [formValues, setFormValues] = useState<{ - name: string; - description?: string; - vlm_model?: string; - chunk_token_num: number; - chunk_overlap_num: number; - caption_enable: number; - ingroup_permission: "EDIT" | "READ_ONLY" | "PRIVATE"; - group_ids: number[]; - }>({ - name: "", - chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, - ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", - group_ids: [], - }); - - // Drive the vlm_model dropdown's visibility off the live Switch value. - // useWatch gives us a re-render whenever caption_enable toggles, without - // forcing the user to manually sync the form value to local state. const captionEnabled = Form.useWatch("caption_enable", form); - // Track in-group permission live so the group_ids picker can be disabled - // at PRIVATE without calling Form.useWatch inside a conditional sub-render - // (which would violate the Rules of Hooks). const ingroupPermission = Form.useWatch("ingroup_permission", form); useEffect(() => { if (!open) return; form.setFieldsValue({ + chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, + chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, + caption_enable: AIDP_CREATE_DEFAULTS.caption_enable === 1, ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", group_ids: [], }); - setFormValues((previous) => ({ - ...previous, - ingroup_permission: isUser ? "PRIVATE" : previous.ingroup_permission, - group_ids: isUser ? [] : previous.group_ids, - })); }, [form, isUser, open]); - // Fetch applicable VLM models from AIDP. Only run when modal is open to - // avoid hitting the (relatively slow) admin endpoint unnecessarily. const { data: vlmModelsData, isLoading: vlmModelsLoading } = useQuery({ queryKey: ["aidp-models", "llm", "KnowledgeBase"], queryFn: () => aidpKnowledgeService.listModels("llm", "KnowledgeBase"), enabled: open, - staleTime: 5 * 60 * 1000, // 5 min + staleTime: 5 * 60 * 1000, }); const vlmModelOptions = useMemo(() => { const models: AidpModelItem[] = vlmModelsData?.models ?? []; return models - .map((m) => m.model_name) - .filter( - (name): name is string => typeof name === "string" && name.length > 0 - ); + .map((model) => model.model_name) + .filter((name): name is string => Boolean(name)); }, [vlmModelsData]); - // Resolve the default VLM model: prefer the hardcoded - // PREFERRED_VLM_MODEL if present, otherwise the first in the list. - // Falls back to PREFERRED_VLM_MODEL (sent to AIDP as-is) when the - // models endpoint returns empty, matching the previous behavior. const defaultVlmModel = useMemo(() => { if (vlmModelOptions.length === 0) return PREFERRED_VLM_MODEL; - if (vlmModelOptions.includes(PREFERRED_VLM_MODEL)) - return PREFERRED_VLM_MODEL; - return vlmModelOptions[0]; + return vlmModelOptions.includes(PREFERRED_VLM_MODEL) + ? PREFERRED_VLM_MODEL + : vlmModelOptions[0]; }, [vlmModelOptions]); - // Pre-populate vlm_model on the form whenever the default is resolved, - // so the user sees a meaningful default on first open. useEffect(() => { - if (!open) return; - if (!defaultVlmModel) return; - const current = form.getFieldValue("vlm_model"); - if (!current || !vlmModelOptions.includes(current)) { + if (!open || !defaultVlmModel) return; + const currentModel = form.getFieldValue("vlm_model"); + if (!currentModel || !vlmModelOptions.includes(currentModel)) { form.setFieldValue("vlm_model", defaultVlmModel); } - }, [open, defaultVlmModel, vlmModelOptions, form]); + }, [defaultVlmModel, form, open, vlmModelOptions]); - // Duplicate name check against existing KBs const existingNames = useMemo( () => new Set( (existingKbs || []) .map((kb) => kb.kds_name?.toLowerCase().trim()) - .filter((n): n is string => !!n) + .filter((name): name is string => Boolean(name)) ), [existingKbs] ); - const handleNext = async () => { + const handleSubmit = async () => { + let knowledgeBaseCreated = false; + // Files AIDP accepted while creating the KB. Reported to the parent so it + // can keep refreshing the document list until they finish processing. + let uploadedFileIds: string[] = []; + let createdKnowledgeBase: AidpKnowledgeBaseItem | null = null; + try { const values = await form.validateFields(); const name = values.name.trim(); @@ -234,169 +260,63 @@ const AidpCreateKbModal: React.FC = ({ return; } - // Save form values before fields unmount - setFormValues({ + if (!validateAidpCreateFiles(fileList, t, message)) return; + + setLoading(true); + const { permission, groupIds } = getAidpCreatePermissionValues( + isUser, + values + ); + const captionEnable = values.caption_enable ? 1 : 0; + + const created = await aidpKnowledgeService.createKb({ name, - description: values.description?.trim() || undefined, - vlm_model: values.caption_enable - ? values.vlm_model || defaultVlmModel || undefined - : "", + description: values.description?.trim() || "", chunk_token_num: values.chunk_token_num ?? AIDP_CREATE_DEFAULTS.chunk_token_num, chunk_overlap_num: values.chunk_overlap_num ?? AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: values.caption_enable ? 1 : 0, - // The permission select is disabled at PRIVATE so users cannot pick - // group_ids while PRIVATE; we always coerce to [] for safety. - ingroup_permission: isUser - ? "PRIVATE" - : (values.ingroup_permission ?? "READ_ONLY"), - group_ids: - isUser || (values.ingroup_permission ?? "READ_ONLY") === "PRIVATE" - ? [] - : Array.isArray(values.group_ids) - ? values.group_ids - : [], - }); - setCurrent(1); - } catch { - // form validation error, do nothing - } - }; - - const handleBack = () => { - // Restore formValues into the Form when remounting Step 0, - // since antd Form clears field values when the Form is unmounted. - form.setFieldsValue(formValues); - setCurrent(0); - }; - - const handleSubmit = async (skipUpload: boolean) => { - let knowledgeBaseCreated = false; - let createdKdsId = ""; - let createdKnowledgeBase: AidpKnowledgeBaseItem | null = null; - try { - if (!formValues.name?.trim()) { - message.error(t("aidpKnowledge.kbNameRequired")); - setCurrent(0); - return; - } - setLoading(true); - - const permission = isUser ? "PRIVATE" : formValues.ingroup_permission; - const groupIds = isUser ? [] : formValues.group_ids; - - // Defense-in-depth: re-validate every file in case beforeUpload was bypassed - if (!skipUpload && fileList.length > 0) { - const validation = validateAidpFiles(fileList); - if (validation.valid.length !== fileList.length) { - setLoading(false); - partitionAidpFiles(fileList, t, message); - return; - } - } - - // Step 1: Create KB - // Aligned with sdk/nexent/core/knowledge_base/mapper.py#build_create_payload - const created = await aidpKnowledgeService.createKb({ - name: formValues.name.trim(), - description: formValues.description || "", - chunk_token_num: formValues.chunk_token_num, - chunk_overlap_num: formValues.chunk_overlap_num, embedding_model: AIDP_CREATE_DEFAULTS.embedding_model, - vlm_model: - formValues.caption_enable === 1 - ? formValues.vlm_model || defaultVlmModel || "" - : "", + vlm_model: captionEnable + ? values.vlm_model || defaultVlmModel || "" + : "", is_personal: AIDP_CREATE_DEFAULTS.is_personal, topk: AIDP_CREATE_DEFAULTS.topk, similarity: AIDP_CREATE_DEFAULTS.similarity, smartsplit: AIDP_CREATE_DEFAULTS.smartsplit, - caption_enable: formValues.caption_enable, - // v7.1: forward in-group permission + groups to the backend so the - // knowledge-base permission row is created in lockstep with the KB. + caption_enable: captionEnable, ingroup_permission: permission, group_ids: groupIds, }); knowledgeBaseCreated = true; - createdKdsId = String(created.kds_id || ""); createdKnowledgeBase = { ...created, - kds_id: createdKdsId, - kds_name: created.kds_name || formValues.name.trim(), - description: created.description ?? formValues.description ?? "", + kds_id: String(created.kds_id || ""), + kds_name: created.kds_name || name, + description: created.description ?? values.description?.trim() ?? "", permission: "EDIT", ingroup_permission: permission, - group_ids: permission === "PRIVATE" ? [] : groupIds, + group_ids: groupIds, resource_status: "ACTIVE", - is_multimodal: formValues.caption_enable === 1, + is_multimodal: captionEnable === 1, }; - // Step 2: Upload files (if any and not skipped) - if (!skipUpload && fileList.length > 0 && created.kds_id) { + if (fileList.length > 0 && created.kds_id) { const result = await aidpKnowledgeService.uploadDocs( created.kds_id, fileList ); - - const failureDetails = result.failed_list.map((item) => { - const reason = i18n.language.startsWith("zh") - ? item.reason_zh || item.reason_en - : item.reason_en || item.reason_zh; - return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; - }); - const failureLines = failureDetails.map((detail, index) => ( -
{detail}
- )); - - if (result.summary.failed > 0 && result.summary.success === 0) { - message.warning( -
-
{t("aidpKnowledge.createKbSuccess")}
- {failureLines.length > 0 ? ( - failureLines - ) : ( -
{t("aidpKnowledge.uploadFailed")}
- )} -
- ); - } else if (result.summary.failed > 0) { - message.info( -
-
{t("aidpKnowledge.createKbSuccess")}
-
- {t("aidpKnowledge.uploadPartial", { - success: result.summary.success, - failed: result.summary.failed, - })} -
- {failureLines} -
- ); - } else { - message.success( - t("aidpKnowledge.createKbSuccess") + - " | " + - t("aidpKnowledge.uploadSuccess", { - count: result.summary.success, - }) - ); - } + showAidpCreateUploadResult(result, i18n.language, t); + uploadedFileIds = collectUploadedFileIds(result.success_list); } else { message.success(t("aidpKnowledge.createKbSuccess")); } handleReset(); - if (createdKnowledgeBase) { - onSuccess(createdKnowledgeBase); - } + if (createdKnowledgeBase) + onSuccess(createdKnowledgeBase, uploadedFileIds); } catch (error) { - const reason = - error instanceof Error && error.message.trim() - ? error.message - : knowledgeBaseCreated - ? t("aidpKnowledge.uploadFailed") - : t("aidpKnowledge.createKbFailed"); + const reason = getAidpCreateErrorReason(error, knowledgeBaseCreated, t); message.error( knowledgeBaseCreated ? `${t("aidpKnowledge.createKbSuccess")} | ${reason}` @@ -404,9 +324,8 @@ const AidpCreateKbModal: React.FC = ({ ); if (knowledgeBaseCreated) { handleReset(); - if (createdKnowledgeBase) { - onSuccess(createdKnowledgeBase); - } + if (createdKnowledgeBase) + onSuccess(createdKnowledgeBase, uploadedFileIds); } } finally { setLoading(false); @@ -415,21 +334,12 @@ const AidpCreateKbModal: React.FC = ({ const handleReset = () => { form.resetFields(); - setCurrent(0); setFileList([]); pendingFilesRef.current = []; if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } - setFormValues({ - name: "", - chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, - ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", - group_ids: [], - }); }; const handleCancel = () => { @@ -437,330 +347,253 @@ const AidpCreateKbModal: React.FC = ({ onCancel(); }; - // ---- Render steps ---- - - const renderStep0 = () => ( - <> -
- - - - - - - - {canConfigureGroupPermissions && ( - <> - {/* USER creation is always a personal PRIVATE KB. */} - - - - - )} - - - {t("aidpKnowledge.createCaptionEnable")} - - - - - } - > - - - - {/* VLM model picker is only relevant when multimodal captioning is - enabled. Hide the dropdown entirely when the Switch is off so - users aren't shown an irrelevant choice, and so the backend - receives an empty ``vlm_model`` (see handleSubmit). */} - {captionEnabled && ( - - {t("aidpKnowledge.createVlmModel")} - - - - - } - > - ({ + label: name, + value: name, + }))} + filterOption={(input, option) => + (option?.label as string) + ?.toLowerCase() + .includes(input.toLowerCase()) ?? false + } + /> + + )} + + ), + }, + ]} + /> +
+ ); }; diff --git a/frontend/ext_components/aidp/components/AidpDocumentList.tsx b/frontend/ext_components/aidp/components/AidpDocumentList.tsx index 3394e4450f..8e8e7a0378 100644 --- a/frontend/ext_components/aidp/components/AidpDocumentList.tsx +++ b/frontend/ext_components/aidp/components/AidpDocumentList.tsx @@ -1,9 +1,9 @@ -import React, { useState, useCallback, useRef } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Pagination, Upload, message, Tooltip } from "antd"; +import { Button, Modal, Pagination, Tag, Upload, message, Tooltip } from "antd"; import { - UploadOutlined, + FileTextOutlined, InboxOutlined, ReloadOutlined, } from "@ant-design/icons"; @@ -12,23 +12,144 @@ import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import type { AidpDocumentItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import { AIDP_ACCEPT_STRING } from "@/const/knowledgeBase"; +import log from "@/lib/logger"; +import { + AIDP_DOC_IN_PROGRESS_STATUSES, + AIDP_DOCUMENT_STATUS, + collectUploadedFileIds, + normalizeAidpDocStatus, +} from "@/lib/aidpDocumentStatus"; import { partitionAidpFiles } from "@/services/uploadService"; +import { getAidpUploadFailureDetails } from "@/ext_components/aidp/services/aidpUploadUtils"; const { Dragger } = Upload; +// AIDP rejects a re-uploaded file with a per-file reason, but that reason is +// shaped exactly like any other upload failure — the user cannot tell a +// duplicate apart from a genuine error. The wording AIDP actually returns is +// "文件已存在,请重命名或删除已有文件" / "File already exists. Please rename or +// delete the existing file.", which never contains the literal word +// "duplicate". Match on those phrases in BOTH languages (the backend returns +// reason_zh and reason_en together, independently of the UI language) plus the +// generic duplicate markers, case-insensitively so upstream capitalisation +// changes cannot silently break the detection. +const DUPLICATE_UPLOAD_REASON_MARKERS = [ + "already exists", + "duplicate", + "已存在", + "重复", +]; + +const isDuplicateUploadReason = ( + ...reasons: Array +): boolean => { + const haystack = reasons + .filter((reason): reason is string => Boolean(reason)) + .join(" ") + .toLowerCase(); + if (!haystack) return false; + return DUPLICATE_UPLOAD_REASON_MARKERS.some((marker) => + haystack.includes(marker) + ); +}; + +/** + * Labels for the in-progress statuses, which all render as a blue tag. + * + * Uploading and extracting are the states a user watches right after an upload, + * so they get the same treatment as processing instead of falling through to the + * verbatim fallback below. + */ +const IN_PROGRESS_STATUS_LABELS: Record = { + [AIDP_DOCUMENT_STATUS.UPLOADING]: "aidpKnowledge.docStatusUploading", + [AIDP_DOCUMENT_STATUS.PROCESSING]: "aidpKnowledge.docStatusProcessing", + [AIDP_DOCUMENT_STATUS.EXTRACTING]: "aidpKnowledge.docStatusExtracting", +}; + +/** + * Table cell showing the ingestion status of a document. + * + * Uploading, processing and extracting are blue because the file is still on its + * way in (being uploaded, chunked/embedded, or having its content extracted) and + * the list keeps refreshing itself until the status resolves; `COMPLETED` green + * and `FAILED` red are the two terminal outcomes. A missing status means the + * backend fell back to the completed-files listing, which only reports ingested + * files — those render as a dash like any other empty cell. An unrecognised + * status is shown verbatim rather than hidden, so a new AIDP status is visible + * instead of silently blank. + */ +const DocumentStatusCell: React.FC<{ status?: string }> = ({ status }) => { + const { t } = useTranslation(); + const normalized = normalizeAidpDocStatus(status); + + if (!normalized) { + return -; + } + + if (AIDP_DOC_IN_PROGRESS_STATUSES.includes(normalized)) { + const labelKey = IN_PROGRESS_STATUS_LABELS[normalized]; + return ( + + {labelKey ? t(labelKey) : status} + + ); + } + + if (normalized === AIDP_DOCUMENT_STATUS.COMPLETED) { + return ( + + {t("aidpKnowledge.docStatusCompleted")} + + ); + } + + if (normalized === AIDP_DOCUMENT_STATUS.FAILED) { + return ( + + {t("aidpKnowledge.docStatusFailed")} + + ); + } + + return ( + + {status} + + ); +}; + +const resolveDownloadFilename = (response: Response, fallback: string) => { + const contentDisposition = response.headers.get("content-disposition") || ""; + const encodedName = /filename\*=UTF-8''([^;]+)/i.exec( + contentDisposition + )?.[1]; + if (encodedName) { + try { + return decodeURIComponent(encodedName); + } catch { + // Use the regular filename or document name when decoding fails. + } + } + const plainName = /filename="?([^";]+)"?/i.exec(contentDisposition)?.[1]; + return plainName || response.headers.get("x-file-name") || fallback; +}; + interface AidpDocumentListProps { activeKb: AidpKnowledgeBaseItem | null; documents: AidpDocumentItem[]; totalDocs: number; - /** True when `totalDocs` came from the AIDP Count API; when false the - * total is a fallback estimate and "共 N 条" should be suppressed. */ + /** True when `totalDocs` came from the AIDP Count API. */ totalReliable: boolean; hasMore: boolean; isLoading: boolean; currentPage: number; pageSize: number; onPageChange: (page: number) => void; - onDocsUploaded: () => void; + /** Called after documents change, with the ids AIDP returned for any newly + * accepted uploads (an empty list for deletions/refreshes). The parent uses + * them to keep refreshing the list until each uploaded file reports a + * terminal processing status. */ + onDocsUploaded: (uploadedFileIds: string[]) => void; onRefresh: () => void; } @@ -47,6 +168,10 @@ const AidpDocumentList: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [downloadingFileUuid, setDownloadingFileUuid] = useState( + null + ); // Antd fires beforeUpload once per file in a multi-select batch. // The `fileList` array may-or-may-not be the same reference across the N // calls (behavior differs between and and antd versions), @@ -56,10 +181,85 @@ const AidpDocumentList: React.FC = ({ const pendingFilesRef = useRef([]); const rafIdRef = useRef(null); + const isUnavailable = + activeKb?.resource_status === "UNAVAILABLE" || + activeKb?.resource_status === "ORPHANED"; + const canDeleteDocuments = + !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; + const canDownloadDocuments = + !!activeKb && + !isUnavailable && + (activeKb.permission === "EDIT" || activeKb.permission === "READ_ONLY"); + + const handleDownload = useCallback( + async (document: AidpDocumentItem) => { + if (!activeKb || !document.file_uuid) return; + setDownloadingFileUuid(document.file_uuid); + try { + const response = await aidpKnowledgeService.downloadDoc( + activeKb.kds_id, + document.file_uuid + ); + const blob = await response.blob(); + const downloadUrl = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + link.href = downloadUrl; + link.download = resolveDownloadFilename(response, document.file_name); + link.click(); + URL.revokeObjectURL(downloadUrl); + message.success(t("aidpKnowledge.downloadSuccess")); + } catch (error) { + log.error("Failed to download AIDP document:", error); + message.error(t("aidpKnowledge.downloadFailed")); + } finally { + setDownloadingFileUuid(null); + } + }, + [activeKb, t] + ); + + const handleDelete = useCallback( + (document: AidpDocumentItem) => { + if (!activeKb || !document.file_uuid) return; + Modal.confirm({ + title: t("aidpKnowledge.confirmDeleteDocTitle"), + content: t("aidpKnowledge.confirmDeleteDocContent"), + okText: t("common.confirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + setDeleting(true); + try { + const result = await aidpKnowledgeService.removeDoc( + activeKb.kds_id, + document.file_uuid + ); + if (result.summary.success > 0) { + message.success(t("aidpKnowledge.deleteDocSuccess")); + } else { + message.error(t("aidpKnowledge.deleteDocFailed")); + } + if (result.summary.success > 0) { + // Deletions are not uploads: nothing to wait for, so the parent + // simply refreshes the list. + onDocsUploaded([]); + } + } catch (error) { + log.error("Failed to delete AIDP document:", error); + message.error(t("aidpKnowledge.deleteDocFailed")); + } finally { + setDeleting(false); + } + }, + }); + }, + [activeKb, onDocsUploaded, t] + ); + const handleUpload = useCallback( async (fileList: File[]) => { - if (!activeKb) return; - if (fileList.length === 0) return; + if (!activeKb || fileList.length === 0) return; setUploading(true); try { @@ -68,11 +268,21 @@ const AidpDocumentList: React.FC = ({ fileList ); + // A rejected duplicate is not an error the user can debug, so give it a + // dedicated message instead of echoing AIDP's "please rename or delete" + // instruction, which is not actionable in this dialog. Every other + // failure keeps the shared reason formatter. const failureDetails = result.failed_list.map((item) => { - const reason = i18n.language.startsWith("zh") - ? item.reason_zh || item.reason_en - : item.reason_en || item.reason_zh; - return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; + if (isDuplicateUploadReason(item.reason_zh, item.reason_en)) { + return t("aidpKnowledge.uploadDuplicateFile", { + fileName: item.file_name, + }); + } + return getAidpUploadFailureDetails( + [item], + i18n.language, + t("aidpKnowledge.uploadFailed") + )[0]; }); const failureLines = failureDetails.map((detail, index) => (
{detail}
@@ -98,12 +308,12 @@ const AidpDocumentList: React.FC = ({ {failureLines} ); - onDocsUploaded(); + onDocsUploaded(collectUploadedFileIds(result.success_list)); } else { message.success( t("aidpKnowledge.uploadSuccess", { count: result.summary.success }) ); - onDocsUploaded(); + onDocsUploaded(collectUploadedFileIds(result.success_list)); } } catch (error) { const reason = @@ -118,7 +328,6 @@ const AidpDocumentList: React.FC = ({ [activeKb, i18n.language, onDocsUploaded, t] ); - // Format file size for display const formatSize = (bytes?: number): string => { if (!bytes || bytes === 0) return "-"; if (bytes < 1024) return `${bytes} B`; @@ -128,206 +337,231 @@ const AidpDocumentList: React.FC = ({ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; }; + const effectiveTotal = totalReliable + ? totalDocs + : hasMore + ? currentPage * pageSize + 1 + : currentPage * pageSize; + + const canUpload = + !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; + + const renderUploadArea = () => { + if (!canUpload) { + const reasonKey = !activeKb + ? "aidpKnowledge.uploadNoKb" + : isUnavailable + ? "aidpKnowledge.uploadKbUnavailable" + : "aidpKnowledge.uploadReadOnly"; + return ( +
+

{t(reasonKey)}

+
+ ); + } + + return ( + { + pendingFilesRef.current.push(_file); + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + const batch = pendingFilesRef.current; + pendingFilesRef.current = []; + rafIdRef.current = null; + + const { valid } = partitionAidpFiles(batch, t, message); + if (valid.length > 0) void handleUpload(valid); + }); + } + return false; + }} + disabled={uploading} + className="!rounded-xl !border-blue-200 !bg-blue-50/30" + > +

+ +

+

+ {uploading + ? t("aidpKnowledge.uploading") + : t("aidpKnowledge.uploadHint")} +

+
+
{t("aidpKnowledge.uploadHintCount")}
+
{t("aidpKnowledge.uploadHintSize")}
+
+ {t("aidpKnowledge.uploadHintFormats")} +
+
+
+ ); + }; + return ( -
- {/* Header */} -
-
-
-

- {activeKb?.kds_name || ""} -

- +
+
+
+
+ +
+
+ {/* An empty `title` leaves the Tooltip inert, so it is safe + while the knowledge base detail is still loading. */} + +

+ {activeKb?.kds_name || ""} +

+
+ {t("aidpKnowledge.tagDocs", { count: totalDocs })}
- -
+ +
- {/* Document table */} -
+
{isLoading ? ( -
+
-
-

+

+

{t("aidpKnowledge.loadingDocs")}

) : documents.length > 0 ? ( -
+
- + - - - + - + - + {documents.map((doc) => ( - - + - - - + ))}
+ {t("aidpKnowledge.docFileName")} + {t("aidpKnowledge.docType")} + + {t("aidpKnowledge.docStatus")} + {t("aidpKnowledge.docSize")} + {t("aidpKnowledge.docCreatedAt")} + {t("aidpKnowledge.docActions")} +
-
- {doc.file_name} -
-
- {doc.file_ino_no} -
+
+ {/* Long file names are clamped by CSS on a + width-bounded inner element (`max-width` on a + table cell is ignored by browsers, so the clamp + must live on the div itself) and the full value + is surfaced through an antd Tooltip on hover, + matching the local knowledge-base list. */} + +
+ {doc.file_name} +
+
+ +
+ {doc.file_ino_no} +
+
+ {doc.file_type || "-"} + + {formatSize(doc.file_size)} + {doc.created_at ? new Date(doc.created_at).toLocaleString() : "-"} +
+ {canDownloadDocuments && ( + + )} + {canDeleteDocuments && ( + + )} +
+
) : ( -
+
{t("aidpKnowledge.noDocuments")}
)}
- {/* Server-side pagination. - AIDP exposes a dedicated Count API for documents which the backend - now calls alongside the list request. When Count succeeds, - `totalReliable` is true and we display the full pagination (page - numbers + "共 N 条"). When Count fails (e.g. the endpoint is not - available on a particular AIDP instance), `totalReliable` is false - and we fall back to simple prev/next mode without a total, using - `has_more` to decide whether the next-page button should enable. */} - {documents.length > 0 && - (() => { - // When total is unreliable we still need antd to know when to - // enable "next": set total just past the current page if there is - // a next page, otherwise clamp to the current page end. - const effectiveTotal = totalReliable - ? totalDocs - : hasMore - ? currentPage * pageSize + 1 - : currentPage * pageSize; - return ( -
- t("aidpKnowledge.showTotal", { count: total }) - : undefined - } - size="small" - /> -
- ); - })()} - - {/* Upload area — gated by ``activeKb.permission`` and ``resource_status``. - - Per v7.1 §7.1, READ_ONLY callers may view existing documents but - must not be able to upload. UNAVAILABLE / ORPHANED KBs are - read-only regardless of permission because the AIDP backend cannot - service the request. The container is replaced with a hint instead - of disabling the Dragger so the visual structure stays consistent - and screen-reader users get an explicit reason. */} -
- {(() => { - const isUnavailable = - activeKb?.resource_status === "UNAVAILABLE" || - activeKb?.resource_status === "ORPHANED"; - const canUpload = - !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; - if (!canUpload) { - const reasonKey = !activeKb - ? "aidpKnowledge.uploadNoKb" - : isUnavailable - ? "aidpKnowledge.uploadKbUnavailable" - : "aidpKnowledge.uploadReadOnly"; - return ( -
-

{t(reasonKey)}

-
- ); - } - return ( - { - // Queue the file and defer validation + upload until the - // synchronous batch of beforeUpload calls finishes. Each batch - // flushes in a single frame so toasts and handleUpload run once. - pendingFilesRef.current.push(_file); - if (rafIdRef.current === null) { - rafIdRef.current = requestAnimationFrame(() => { - const batch = pendingFilesRef.current; - pendingFilesRef.current = []; - rafIdRef.current = null; - - const { valid } = partitionAidpFiles(batch, t, message); - if (valid.length > 0) { - handleUpload(valid); - } - }); - } - return false; - }} - disabled={uploading} - > -

- -

-

- {uploading - ? t("aidpKnowledge.uploading") - : t("aidpKnowledge.uploadHint")} -

-
-
{t("aidpKnowledge.uploadHintCount")}
-
{t("aidpKnowledge.uploadHintSize")}
-
- {t("aidpKnowledge.uploadHintFormats")} -
-
-
- ); - })()} + {documents.length > 0 && ( +
+ t("aidpKnowledge.showTotal", { count }) + : undefined + } + size="small" + /> +
+ )} + +
+ {renderUploadArea()}
); diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx new file mode 100644 index 0000000000..85b2d77a87 --- /dev/null +++ b/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { Button, Form, Input, Select } from "antd"; +import type { TFunction } from "i18next"; + +import { AIDP_KNOWLEDGE_BASE_NAME_PATTERN } from "@/const/knowledgeBase"; +import type { AidpGroupOption } from "../hooks/useAidpGroupOptions"; + +export const AIDP_MODAL_STYLES = { + container: { overflow: "hidden", borderRadius: 16, padding: 0 }, + body: { padding: 0 }, + footer: { + margin: 0, + padding: "12px 20px 16px", + borderTop: "1px solid #f0f0f0", + }, +}; + +interface AidpModalHeaderProps { + title: string; + subtitle: string; +} + +export const AidpKnowledgeBaseModalHeader: React.FC = ({ + title, + subtitle, +}) => ( +
+

+ {title} +

+

{subtitle}

+
+); + +interface AidpModalFooterProps { + onCancel: () => void; + onSubmit: () => void; + loading: boolean; + cancelText: string; + submitText: string; +} + +export const AidpKnowledgeBaseModalFooter: React.FC = ({ + onCancel, + onSubmit, + loading, + cancelText, + submitText, +}) => ( +
+ + +
+); + +interface AidpKnowledgeBaseBasicFieldsProps { + t: TFunction; +} + +export const AidpKnowledgeBaseBasicFields: React.FC< + AidpKnowledgeBaseBasicFieldsProps +> = ({ t }) => ( + <> + + + + + + + +); + +interface AidpPermissionFieldsProps { + t: TFunction; + groupOptions: AidpGroupOption[]; + ingroupPermission?: string; + showSearch?: boolean; +} + +const hasRequiredGroupIds = (permission: string, value: unknown) => + permission === "PRIVATE" || (Array.isArray(value) && value.length > 0); + +export const AidpKnowledgeBasePermissionFields: React.FC< + AidpPermissionFieldsProps +> = ({ t, groupOptions, ingroupPermission, showSearch = false }) => ( + <> + + + + +); diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index 9dac6c97c5..d9463c4988 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { App, Row, Col, Modal } from "antd"; @@ -9,8 +9,13 @@ import { InfoCircleFilled } from "@ant-design/icons"; import { SETUP_PAGE_CONTAINER, TWO_COLUMN_LAYOUT, - STANDARD_CARD, } from "@/const/layoutConstants"; +import { KB_SEARCH_DEBOUNCE_MS } from "@/const/knowledgeBase"; +import { + AIDP_DOC_STATUS_POLL_MS, + AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS, + findPendingUploadIds, +} from "@/lib/aidpDocumentStatus"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService, { type AidpKbDetail, @@ -40,12 +45,17 @@ const AidpKnowledgeConfiguration: React.FC = () => { // which may not contain the currently active KB. `selectedKb` is the item // itself — set on selection, kept stable across list refetches. const [activeKbId, setActiveKbId] = useState(null); - const [selectedKb, setSelectedKb] = useState(null); - const [activeKbDetail, setActiveKbDetail] = useState(null); + const [selectedKb, setSelectedKb] = useState( + null + ); + const [, setActiveKbDetail] = useState(null); const [documents, setDocuments] = useState([]); const [totalDocs, setTotalDocs] = useState(0); const [docHasMore, setDocHasMore] = useState(false); const [docTotalReliable, setDocTotalReliable] = useState(true); + // Files still being processed across the whole knowledge base (not just the + // visible page). Drives the status polling below. + const [docProcessingCount, setDocProcessingCount] = useState(0); const [loadingDocs, setLoadingDocs] = useState(false); // ---- Pagination state ---- @@ -57,37 +67,60 @@ const AidpKnowledgeConfiguration: React.FC = () => { // ---- Modal state ---- const [createModalOpen, setCreateModalOpen] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); - const [editingKb, setEditingKb] = useState(null); + const [editingKb, setEditingKb] = useState( + null + ); + + // ---- Keyword search state ---- + // `kbKeyword` is the raw input value and keeps the text field responsive; + // `debouncedKbKeyword` is what actually drives requests. Splitting them means + // typing never waits on the network, and a pause settles on one request. + const [kbKeyword, setKbKeyword] = useState(""); + const [debouncedKbKeyword, setDebouncedKbKeyword] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedKbKeyword(kbKeyword.trim()); + }, KB_SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [kbKeyword]); // ---- Fetch KB list (server-side pagination: each page fetches page_size items + Count total) ---- - const fetchKbs = useCallback(async (page: number = 1) => { - setLoadingKbs(true); - try { - const result = await aidpKnowledgeService.listKbs( - page, - KB_PAGE_SIZE, - ); - setKbs(result.value); - setKbTotal(result.total_count ?? result.value.length); - setKbHasMore(result.has_more ?? false); - setKbTotalReliable(result.total_reliable !== false); - setKbPage(page); - } catch (error) { - log.error("Failed to fetch AIDP knowledge bases:", error); - appMessage.error(t("aidpKnowledge.fetchKbsFailed")); - setKbs([]); - setKbTotal(0); - setKbHasMore(false); - setKbTotalReliable(false); - } finally { - setLoadingKbs(false); - } - }, [appMessage, t]); + const fetchKbs = useCallback( + async (page: number = 1, keyword: string = "") => { + setLoadingKbs(true); + try { + const result = await aidpKnowledgeService.listKbs( + page, + KB_PAGE_SIZE, + keyword + ); + setKbs(result.value); + setKbTotal(result.total_count ?? result.value.length); + setKbHasMore(result.has_more ?? false); + setKbTotalReliable(result.total_reliable !== false); + setKbPage(page); + } catch (error) { + log.error("Failed to fetch AIDP knowledge bases:", error); + appMessage.error(t("aidpKnowledge.fetchKbsFailed")); + setKbs([]); + setKbTotal(0); + setKbHasMore(false); + setKbTotalReliable(false); + } finally { + setLoadingKbs(false); + } + }, + [appMessage, t] + ); - // Auto-fetch on mount + // Fetch on mount, and again whenever the debounced keyword settles. + // Every keyword change restarts at page 1 on purpose: the previous page + // number is meaningless against a different result set and would otherwise + // render an empty list whenever the filtered set is shorter than that page. useEffect(() => { - fetchKbs(); - }, [fetchKbs]); + fetchKbs(1, debouncedKbKeyword); + }, [fetchKbs, debouncedKbKeyword]); // ---- Cleanup legacy localStorage credentials on mount ---- // v7.1: AIDP credentials moved backend-side; frontends that pre-date the @@ -105,46 +138,129 @@ const AidpKnowledgeConfiguration: React.FC = () => { }, []); // ---- Fetch documents for active KB (server-side pagination) ---- + // `silent` refreshes are used by the status poller: the table keeps rendering + // the previous page instead of flashing the loading placeholder, and a failed + // poll stays out of the way (logged only) so a short upstream hiccup cannot + // spam the user with a toast every interval. The manual refresh button always + // runs a non-silent fetch, so errors stay visible when the user asks for them. const fetchDocs = useCallback( - async (kbId: string, page: number = 1) => { - setLoadingDocs(true); + async (kbId: string, page: number = 1, options?: { silent?: boolean }) => { + const silent = options?.silent === true; + if (!silent) setLoadingDocs(true); try { const result = await aidpKnowledgeService.listDocs( kbId, page, - DOC_PAGE_SIZE, + DOC_PAGE_SIZE ); const count = result.total_count ?? result.value.length; setDocuments(result.value); setTotalDocs(count); setDocHasMore(result.has_more ?? false); setDocTotalReliable(result.total_reliable !== false); + setDocProcessingCount(result.processing_count ?? 0); setDocPage(page); + + // Settle the upload watch: a just-uploaded file counts as done only + // once it is listed with a terminal status. Files AIDP has not listed + // yet stay pending on purpose, so the refresh keeps running instead of + // stopping while the list is still missing the upload. + if (pendingUploadIdsRef.current.length > 0) { + const stillPending = findPendingUploadIds( + pendingUploadIdsRef.current, + result.value + ); + pendingUploadIdsRef.current = stillPending; + if (stillPending.length === 0) setUploadWatchActive(false); + } } catch (error) { log.error("Failed to fetch AIDP documents:", error); - appMessage.error(t("aidpKnowledge.fetchDocsFailed")); - setDocuments([]); - setTotalDocs(0); - setDocHasMore(false); - setDocTotalReliable(false); + if (!silent) { + appMessage.error(t("aidpKnowledge.fetchDocsFailed")); + setDocuments([]); + setTotalDocs(0); + setDocHasMore(false); + setDocTotalReliable(false); + setDocProcessingCount(0); + } } finally { - setLoadingDocs(false); + if (!silent) setLoadingDocs(false); } }, [appMessage, t] ); + // ---- Upload watch - state - --- + // Files the user just uploaded and that are not settled yet. Kept in a ref + // so `fetchDocs` can settle them without becoming a new function on every + // watch update (which would restart the polling interval). + const pendingUploadIdsRef = useRef([]); + const uploadWatchStartedAtRef = useRef(0); + const [uploadWatchActive, setUploadWatchActive] = useState(false); + + /** Start refreshing until every just-uploaded file reaches a terminal state. */ + const startUploadWatch = useCallback((uploadedFileIds: string[]) => { + if (uploadedFileIds.length === 0) return; + pendingUploadIdsRef.current = uploadedFileIds; + uploadWatchStartedAtRef.current = Date.now(); + setUploadWatchActive(true); + }, []); + + /** Stop watching uploads (no upload in flight to wait for). */ + const stopUploadWatch = useCallback(() => { + pendingUploadIdsRef.current = []; + setUploadWatchActive(false); + }, []); + + // ---- Poll the document list while work is outstanding ---- + // Two independent reasons to poll: + // * a file just uploaded by the user has not settled yet (`uploadWatchActive`), + // which covers the window where AIDP has accepted the upload but does not + // list it yet - polling on processing_count alone would never start there; + // * the knowledge base still reports files being ingested + // (`docProcessingCount`, counted across the whole KB, so a processing file + // on another page keeps the status column live). + // Polling stops once neither holds: every file is COMPLETED or FAILED. The + // upload watch additionally gives up after AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS so + // a silently dropped upload cannot keep the list refreshing forever. + const shouldPollDocs = uploadWatchActive || docProcessingCount > 0; + useEffect(() => { + if (!activeKbId || !shouldPollDocs) return; + const tick = () => { + if ( + uploadWatchActive && + Date.now() - uploadWatchStartedAtRef.current > + AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS + ) { + stopUploadWatch(); + return; + } + void fetchDocs(activeKbId, docPage, { silent: true }); + }; + const timer = window.setInterval(tick, AIDP_DOC_STATUS_POLL_MS); + return () => window.clearInterval(timer); + }, [ + activeKbId, + docPage, + shouldPollDocs, + uploadWatchActive, + fetchDocs, + stopUploadWatch, + ]); + // ---- Handle KB selection ---- const handleSelectKb = useCallback( (kb: AidpKnowledgeBaseItem) => { + stopUploadWatch(); setActiveKbId(kb.kds_id); setSelectedKb(kb); setDocPage(1); setDocHasMore(false); setDocTotalReliable(true); + setDocProcessingCount(0); fetchDocs(kb.kds_id, 1); }, - [fetchDocs] + [fetchDocs, stopUploadWatch] ); // ---- Handle KB deletion ---- @@ -166,6 +282,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { // If the deleted KB was active, clear selection if (activeKbId === kb.kds_id) { + stopUploadWatch(); setActiveKbId(null); setSelectedKb(null); setActiveKbDetail(null); @@ -173,18 +290,27 @@ const AidpKnowledgeConfiguration: React.FC = () => { setTotalDocs(0); setDocHasMore(false); setDocTotalReliable(true); + setDocProcessingCount(0); setDocPage(1); } - // Refresh list - fetchKbs(kbPage); - } catch (error) { + // Refresh list, keeping the active search filter applied + fetchKbs(kbPage, debouncedKbKeyword); + } catch { appMessage.error(t("aidpKnowledge.deleteKbFailed")); } }, }); }, - [activeKbId, appMessage, t, fetchKbs, kbPage] + [ + activeKbId, + appMessage, + t, + fetchKbs, + kbPage, + debouncedKbKeyword, + stopUploadWatch, + ] ); // ---- Edit KB ---- @@ -212,9 +338,11 @@ const AidpKnowledgeConfiguration: React.FC = () => { // ---- After create success ---- // The create response already contains the resource. Insert it locally - // instead of scanning up to 50 expensive server-side pages. + // instead of scanning up to 50 expensive server-side pages. Files uploaded + // together with the KB are watched like any other upload so their processing + // status appears without a manual refresh. const handleCreateKbSuccess = useCallback( - (newKb: AidpKnowledgeBaseItem) => { + (newKb: AidpKnowledgeBaseItem, uploadedFileIds: string[] = []) => { setCreateModalOpen(false); setKbs((current) => [newKb, ...current.filter((kb) => kb.kds_id !== newKb.kds_id)].slice( @@ -228,43 +356,66 @@ const AidpKnowledgeConfiguration: React.FC = () => { return nextTotal; }); setKbTotalReliable(true); + stopUploadWatch(); + startUploadWatch(uploadedFileIds); setActiveKbId(newKb.kds_id); setSelectedKb(newKb); setActiveKbDetail(newKb); setDocPage(1); setDocHasMore(false); setDocTotalReliable(true); + setDocProcessingCount(0); void fetchDocs(newKb.kds_id, 1); }, - [fetchDocs, kbPage] + [fetchDocs, kbPage, startUploadWatch, stopUploadWatch] ); + // ---- Refresh the active KB metadata (counts / name) ---- + const refreshActiveKbDetail = useCallback(() => { + if (!activeKbId) return; + void aidpKnowledgeService + .getKb(activeKbId) + .then((detail) => { + const refreshed = { + ...selectedKb, + ...detail, + kds_id: activeKbId, + kds_name: detail.kds_name || selectedKb?.kds_name || activeKbId, + } as AidpKnowledgeBaseItem; + setSelectedKb(refreshed); + setActiveKbDetail(detail); + setKbs((current) => + current.map((kb) => (kb.kds_id === activeKbId ? refreshed : kb)) + ); + }) + .catch((error) => + log.error("Failed to refresh active AIDP KB detail:", error) + ); + }, [activeKbId, selectedKb]); + // ---- After documents uploaded ---- - const handleDocsUploaded = useCallback(() => { - if (activeKbId) { + // Refresh immediately so the uploaded files show up without the user having + // to press refresh, then watch them until AIDP reports a terminal status. + const handleDocsUploaded = useCallback( + (uploadedFileIds: string[]) => { + startUploadWatch(uploadedFileIds); + if (!activeKbId) return; // Reset doc pagination to page 1 so data and pagination UI stay in sync setDocPage(1); void fetchDocs(activeKbId, 1); - void aidpKnowledgeService - .getKb(activeKbId) - .then((detail) => { - const refreshed = { - ...selectedKb, - ...detail, - kds_id: activeKbId, - kds_name: detail.kds_name || selectedKb?.kds_name || activeKbId, - } as AidpKnowledgeBaseItem; - setSelectedKb(refreshed); - setActiveKbDetail(detail); - setKbs((current) => - current.map((kb) => (kb.kds_id === activeKbId ? refreshed : kb)) - ); - }) - .catch((error) => - log.error("Failed to refresh active AIDP KB detail:", error) - ); - } - }, [activeKbId, fetchDocs, selectedKb]); + refreshActiveKbDetail(); + }, + [activeKbId, fetchDocs, refreshActiveKbDetail, startUploadWatch] + ); + + // ---- Manual refresh (refresh button) ---- + // Refreshes the visible page and the KB metadata but never starts an upload + // watch: the user is not waiting for a file they just added. + const handleRefreshDocs = useCallback(() => { + if (!activeKbId) return; + void fetchDocs(activeKbId, docPage); + refreshActiveKbDetail(); + }, [activeKbId, docPage, fetchDocs, refreshActiveKbDetail]); // Active KB item is stored in `selectedKb` state (not derived from `kbs`), // because the KB list is server-paginated and refetching it after upload @@ -273,18 +424,17 @@ const AidpKnowledgeConfiguration: React.FC = () => { return (
- {/* Two-column layout — content-sized cards with a single - scroll container; no card stretches to viewport height. */} -
- +
+ {/* Left column: KB list */} { hasMore={kbHasMore} currentPage={kbPage} pageSize={KB_PAGE_SIZE} - onPageChange={(page) => fetchKbs(page)} + keyword={kbKeyword} + onKeywordChange={setKbKeyword} + onPageChange={(page) => fetchKbs(page, debouncedKbKeyword)} onSelect={handleSelectKb} - onRefresh={() => fetchKbs(kbPage)} + onRefresh={() => fetchKbs(kbPage, debouncedKbKeyword)} onCreateNew={() => setCreateModalOpen(true)} onEdit={handleEditKb} onDelete={handleDeleteKb} @@ -311,6 +463,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { {/* Right column: Document list or empty state */} { isLoading={loadingDocs} currentPage={docPage} pageSize={DOC_PAGE_SIZE} - onPageChange={(page) => fetchDocs(activeKbId!, page)} + onPageChange={(page) => { + // The upload watch only makes sense on the page the upload + // landed on; the status poller still covers other pages. + stopUploadWatch(); + void fetchDocs(activeKbId!, page); + }} onDocsUploaded={handleDocsUploaded} - onRefresh={handleDocsUploaded} + onRefresh={handleRefreshDocs} /> ) : ( -
-
-
-
- -
-

- {t("aidpKnowledge.selectKbTitle")} -

-

- {t("aidpKnowledge.selectKbHint")} -

+
+
+
+
+

+ {t("aidpKnowledge.selectKbTitle")} +

+

+ {t("aidpKnowledge.selectKbHint")} +

)} diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index 2911d32a05..b21d0bd9fc 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -1,12 +1,19 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Pagination, Tag, Tooltip } from "antd"; +import { Button, Input, Pagination, Tooltip } from "antd"; import { - PlusOutlined, - ReloadOutlined, -} from "@ant-design/icons"; -import { SquarePen, Trash2 } from "lucide-react"; + BookOpen, + CircleOff, + Eye, + FolderOpen, + Glasses, + PencilRuler, + Search, + SquarePen, + Trash2, +} from "lucide-react"; +import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import { useGroupList } from "@/hooks/group/useGroupList"; @@ -18,12 +25,18 @@ interface AidpKnowledgeListProps { activeKbId: string | null; isLoading: boolean; total: number; - /** True when `total` came from AIDP Count API (reliable). When false we - * show a simple prev/next pagination without "共 N 条". */ + /** True when `total` came from AIDP Count API. */ totalReliable: boolean; hasMore: boolean; currentPage: number; pageSize: number; + /** Raw search box value. The parent debounces it before querying, so this is + * intentionally the un-debounced text the user is currently typing. The + * filtering itself happens server-side: AIDP narrows the catalog by this + * keyword, so the page below renders exactly what the API returned and the + * reported total always matches the rendered cards. */ + keyword: string; + onKeywordChange: (value: string) => void; onPageChange: (page: number) => void; onSelect: (kb: AidpKnowledgeBaseItem) => void; onRefresh: () => void; @@ -32,6 +45,20 @@ interface AidpKnowledgeListProps { onDelete: (kb: AidpKnowledgeBaseItem) => void; } +const permissionIcon = (permission?: string) => { + const props = { size: 13, className: "text-gray-500" }; + switch (permission) { + case "EDIT": + return ; + case "READ_ONLY": + return ; + case "PRIVATE": + return ; + default: + return ; + } +}; + const AidpKnowledgeList: React.FC = ({ kbs, activeKbId, @@ -41,6 +68,8 @@ const AidpKnowledgeList: React.FC = ({ hasMore, currentPage, pageSize, + keyword, + onKeywordChange, onPageChange, onSelect, onRefresh, @@ -50,221 +79,322 @@ const AidpKnowledgeList: React.FC = ({ }) => { const { t } = useTranslation(); - // Load groups for the current tenant so we can render group_ids as names. - // ``useAuthorizationContext`` is the right hook here (the similarly-named - // ``useAuthenticationContext`` carries only ``session`` — no ``user`` object). const { user } = useAuthorizationContext(); const tenantId = user?.tenantId ?? null; const { data: groupListData } = useGroupList(tenantId); const groupById = useMemo(() => { const map = new Map(); - (groupListData?.groups ?? []).forEach((g) => { - map.set(g.group_id, g.group_name); + (groupListData?.groups ?? []).forEach((group) => { + map.set(group.group_id, group.group_name); }); return map; }, [groupListData]); - // Convert group ids to names, skipping any ids that don't resolve - // (e.g. the group was deleted or the list is not yet loaded). Aligned - // with the local-knowledge-base list renderer. - const getGroupNames = (groupIds: number[] | undefined): string[] => { - if (!Array.isArray(groupIds) || groupIds.length === 0) return []; - return groupIds + // The keyword filter lives on the server: the parent forwards it to AIDP, and + // filtering the returned page again here would hide results whenever AIDP's + // matching is not a plain substring of the name/description. Only the order is + // local, so the most recently updated KB stays on top of the page. + const displayedKbs = useMemo( + () => + [...kbs].sort((a, b) => { + const aTime = Date.parse(a.updated_at || a.created_at || "") || 0; + const bTime = Date.parse(b.updated_at || b.created_at || "") || 0; + return bTime - aTime; + }), + [kbs] + ); + + const getGroupNames = (groupIds?: number[]) => + (groupIds ?? []) .map((id) => groupById.get(id)) - .filter((name): name is string => typeof name === "string" && name.length > 0); + .filter((name): name is string => Boolean(name)); + + const permissionLabel = (permission?: string) => + t(`knowledgeBase.ingroup.permission.${permission || "DEFAULT"}`); + + const renderStatusTag = (kb: AidpKnowledgeBaseItem) => { + const isUnavailable = + kb.resource_status === "UNAVAILABLE" || kb.resource_status === "ORPHANED"; + if (isUnavailable) { + return ( + + {t("aidpKnowledge.kbUnavailable")} + + ); + } + if (kb.permission === "READ_ONLY") { + return ( + + {t("aidpKnowledge.kbReadOnly")} + + ); + } + return null; }; - // Sort alphabetically by name - const displayedKbs = useMemo(() => { - return [...kbs].sort((a, b) => - (a.kds_name || "").localeCompare(b.kds_name || "") + const renderKnowledgeCard = (kb: AidpKnowledgeBaseItem) => { + const isActive = activeKbId === kb.kds_id; + const isUnavailable = + kb.resource_status === "UNAVAILABLE" || kb.resource_status === "ORPHANED"; + const canModify = kb.permission === "EDIT" && !isUnavailable; + const groupNames = getGroupNames(kb.group_ids); + const documentCount = kb.document_count ?? 0; + const chunkCount = kb.chunk_count ?? 0; + const permission = kb.ingroup_permission || "PRIVATE"; + + return ( +
onSelect(kb)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(kb); + } + }} + > +
+
+
+ +
+
+

+ {kb.kds_name} +

+ + AIDP + +
+
+ +
+ {canModify && ( + +
+
+ +

+ {kb.description?.trim() || t("knowledgeBase.description.default")} +

+ +
+ + {t("knowledgeBase.tag.documents", { count: documentCount })} + + + {t("knowledgeBase.tag.chunks", { count: chunkCount })} + + {kb.embedding_model && kb.embedding_model !== "default" && ( + + {kb.embedding_model} + + )} + {kb.is_multimodal && ( + + multimodal + + )} + {renderStatusTag(kb)} + + {permission === "PRIVATE" ? ( + + {permissionIcon(permission)} + {permissionLabel(permission)} + + ) : ( + groupNames.slice(0, 2).map((groupName) => ( + + {groupName} + + )) + )} + +
+ +
+ + {t("knowledgeBase.tag.updatedAt", { + date: + kb.updated_at || kb.created_at + ? new Date( + kb.updated_at || kb.created_at || "" + ).toLocaleDateString() + : t("aidpKnowledge.createdAtUnknown"), + })} + + + {permissionLabel(permission)} + +
+
); - }, [kbs]); + }; + + let effectiveTotal = currentPage * pageSize; + if (totalReliable) { + effectiveTotal = total; + } else if (hasMore) { + effectiveTotal += 1; + } return ( -
- {/* Header */} -
-
-

- {t("aidpKnowledge.kbListTitle")} -

-
+
+
+
+
+
+ +
+
+

+ {t("knowledgeBase.page.title")} +

+

+ {t("knowledgeBase.page.description")} +

+
+
+ +
-
- {/* List */} -
- {displayedKbs.length > 0 ? ( -
- {displayedKbs.map((kb) => { - const isActive = activeKbId === kb.kds_id; - const isUnavailable = - kb.resource_status === "UNAVAILABLE" || - kb.resource_status === "ORPHANED"; - // Only EDIT-level callers may modify the KB or its files. - const canModify = kb.permission === "EDIT" && !isUnavailable; +
+

+ {t("knowledgeBase.page.all")} + + {t("knowledgeBase.page.count", { count: total })} + +

+ } + value={keyword} + onChange={(event) => onKeywordChange(event.target.value)} + className="h-10 min-w-[240px] max-w-[420px] flex-1 !rounded-lg" + allowClear + /> +
+
- return ( -
onSelect(kb)} - > -
-
-
-

- {kb.kds_name} -

- {isUnavailable && ( - - {t("aidpKnowledge.kbUnavailable")} - - )} - {kb.permission === "READ_ONLY" && !isUnavailable && ( - - {t("aidpKnowledge.kbReadOnly")} - - )} -
- {kb.description && ( -

- {kb.description} -

- )} -
- {kb.ingroup_permission === "PRIVATE" && ( - - {t("knowledgeBase.ingroup.permission.PRIVATE")} - - )} - {/* Authorized user-group tags. Aligned with the local - knowledge base list: only render group names when - ``ingroup_permission !== "PRIVATE"``, each group - gets its own blue tag, and when there are no - groups to show we render nothing (no "not - authorized" fallback). Gated by the ``group:read`` - permission so users without group visibility see - the KB card cleanly without the tag area. */} - - {kb.ingroup_permission !== "PRIVATE" && - getGroupNames(kb.group_ids).map((groupName, idx) => ( - - {groupName} - - ))} - - {kb.created_at ? ( - - {t("aidpKnowledge.createdAt", { - date: new Date(kb.created_at).toLocaleDateString(), - })} - - ) : ( - {t("aidpKnowledge.createdAtUnknown")} - )} -
-
-
- {canModify && ( - -
-
-
- ); - })} +
+ {isLoading && kbs.length === 0 ? ( +
+ Loading...
) : ( -
- {t("aidpKnowledge.listEmpty")} +
+ + {displayedKbs.map(renderKnowledgeCard)}
)} -
- {/* Server-side pagination. - AIDP exposes a dedicated Count API for KBs which the backend calls - alongside the list request. When Count succeeds, `totalReliable` - is true and we display the full pagination (page numbers + - "共 N 条"). When Count fails (e.g. endpoint unavailable), we fall - back to simple prev/next mode using `has_more`. */} - {kbs.length > 0 && (() => { - const effectiveTotal = totalReliable - ? total - : (hasMore - ? currentPage * pageSize + 1 - : currentPage * pageSize); - return ( -
- t("aidpKnowledge.showTotal", { count: total }) - : undefined - } - size="small" - /> + {!isLoading && displayedKbs.length === 0 && ( +
+ {keyword.trim() + ? t("knowledgeBase.list.noResults") + : t("aidpKnowledge.listEmpty")}
- ); - })()} + )} +
+ + {kbs.length > 0 && ( +
+ t("aidpKnowledge.showTotal", { count }) + : undefined + } + size="small" + /> +
+ )}
); }; diff --git a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx index cb9a0317e5..415290f067 100644 --- a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx @@ -1,16 +1,21 @@ "use client"; -import React, { useEffect, useMemo } from "react"; +import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Modal, Form, Input, Select, message } from "antd"; +import { Collapse, Modal, Form, message } from "antd"; +import { SettingOutlined } from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; -import { AIDP_KNOWLEDGE_BASE_NAME_PATTERN } from "@/const/knowledgeBase"; -import { useGroupList } from "@/hooks/group/useGroupList"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { USER_ROLES } from "@/const/auth"; +import { useAidpGroupOptions } from "../hooks/useAidpGroupOptions"; +import { + AIDP_MODAL_STYLES, + AidpKnowledgeBaseBasicFields, + AidpKnowledgeBaseModalFooter, + AidpKnowledgeBaseModalHeader, + AidpKnowledgeBasePermissionFields, +} from "./AidpKnowledgeBaseModalParts"; interface AidpUpdateKbModalProps { open: boolean; @@ -29,24 +34,9 @@ const AidpUpdateKbModal: React.FC = ({ const [form] = Form.useForm(); const [loading, setLoading] = React.useState(false); - // Mirror the create-modal wiring: the authorization context exposes - // ``user.tenantId``, which we feed into ``useGroupList`` to enumerate - // the tenant's groups for the access-group picker below. - const { user } = useAuthorizationContext(); - const isUser = user?.role === USER_ROLES.USER; - const canConfigureGroupPermissions = !!user && !isUser; - const tenantId = user?.tenantId ?? null; - const { data: groupListData } = useGroupList( - canConfigureGroupPermissions ? tenantId : null - ); - const groupOptions = useMemo( - () => - (groupListData?.groups ?? []).map((g) => ({ - value: g.group_id, - label: g.group_name, - })), - [groupListData] - ); + const { isUser, canConfigureGroupPermissions, groupOptions } = + useAidpGroupOptions(); + const [advancedOpen, setAdvancedOpen] = React.useState(false); const ingroupPermission = Form.useWatch("ingroup_permission", form); @@ -54,20 +44,21 @@ const AidpUpdateKbModal: React.FC = ({ // that predate the column — normalize to an empty array so the Select // (mode="multiple") receives a value shape it accepts. useEffect(() => { - if (open && knowledgeBase) { - form.setFieldsValue({ - name: knowledgeBase.kds_name, - description: knowledgeBase.description || "", - ingroup_permission: isUser - ? "PRIVATE" - : knowledgeBase.ingroup_permission || "READ_ONLY", - group_ids: isUser - ? [] - : Array.isArray(knowledgeBase.group_ids) - ? knowledgeBase.group_ids - : [], - }); - } + if (!open) return; + setAdvancedOpen(false); + if (!knowledgeBase) return; + form.setFieldsValue({ + name: knowledgeBase.kds_name, + description: knowledgeBase.description || "", + ingroup_permission: isUser + ? "PRIVATE" + : knowledgeBase.ingroup_permission || "READ_ONLY", + group_ids: isUser + ? [] + : Array.isArray(knowledgeBase.group_ids) + ? knowledgeBase.group_ids + : [], + }); }, [open, knowledgeBase, form, isUser]); const handleOk = async () => { @@ -164,96 +155,74 @@ const AidpUpdateKbModal: React.FC = ({ return ( + } > -
- + + - - - - - - {canConfigureGroupPermissions && ( - <> - + {canConfigureGroupPermissions && ( + + setAdvancedOpen( + Array.isArray(keys) + ? keys.includes("advanced") + : keys === "advanced" + ) + } + items={[ { - required: true, - message: t("aidpKnowledge.createIngroupPermissionRequired"), + key: "advanced", + label: ( + + + {t("aidpKnowledge.createAdvancedOptions")} + + ), + children: ( +
+ +
+ ), }, ]} - > - -
- - )} -
+ /> + )} + +
); }; diff --git a/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts b/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts new file mode 100644 index 0000000000..ff0a42e36b --- /dev/null +++ b/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; + +import { USER_ROLES } from "@/const/auth"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { useGroupList } from "@/hooks/group/useGroupList"; + +export interface AidpGroupOption { + value: number; + label: string; +} + +export const useAidpGroupOptions = () => { + const { user } = useAuthorizationContext(); + const isUser = user?.role === USER_ROLES.USER; + const canConfigureGroupPermissions = Boolean(user) && !isUser; + const tenantId = user?.tenantId ?? null; + const { data: groupListData } = useGroupList( + canConfigureGroupPermissions ? tenantId : null + ); + const groupOptions = useMemo( + () => + (groupListData?.groups ?? []).map((group) => ({ + value: group.group_id, + label: group.group_name, + })), + [groupListData] + ); + + return { isUser, canConfigureGroupPermissions, groupOptions }; +}; diff --git a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts index 4059242337..80fb4830e8 100644 --- a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts +++ b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts @@ -1,7 +1,7 @@ /** * AIDP Knowledge Base Management Service * - * Wraps the 8 AIDP management backend endpoints. + * Wraps the AIDP management backend endpoints. * Credentials (server_url, api_key) are read by the backend from environment variables. */ @@ -29,19 +29,25 @@ export interface AidpKbDetail { ingroup_permission?: "EDIT" | "READ_ONLY" | "PRIVATE"; group_ids?: number[]; resource_status?: - | "ACTIVE" - | "CREATING" - | "DELETE_PENDING" - | "ORPHANED" - | "UNAVAILABLE"; + "ACTIVE" | "CREATING" | "DELETE_PENDING" | "ORPHANED" | "UNAVAILABLE"; } export interface AidpDocumentItem { - file_ino_no: string; + file_uuid: string; + file_ino_no: number; file_name: string; file_size?: number; file_type?: string; created_at?: string; + /** + * Processing status reported by the AIDP file history endpoint: + * `PROCESSING` | `COMPLETED` | `FAILED` (upper-cased by the backend). + * Absent when the backend falls back to the completed-files listing, which + * only ever reports ingested files. + */ + status?: string; + /** Channel directory the file was ingested from. */ + dir_path?: string; } export interface AidpDocumentListResponse { @@ -52,9 +58,16 @@ export interface AidpDocumentListResponse { * fallback estimate when Count fails (false). When false the frontend * should treat the total as approximate and avoid displaying "共 N 条". */ total_reliable?: boolean; + /** + * Number of files still being processed across the WHOLE knowledge base + * (not just the returned page). The list polls while this is greater than + * zero and stops once every file has reached a terminal status. + */ + processing_count?: number; } export interface AidpUploadSuccessItem { + file_uuid: string; file_name: string; file_type: string; file_size: number; @@ -78,6 +91,62 @@ export interface AidpUploadResponse { failed_list: AidpUploadFailedItem[]; } +export interface AidpDocumentOperationItem { + file_uuid: string; +} + +export interface AidpDocumentRemoveResponse { + summary: { + total: number; + success: number; + failed: number; + }; + success_list: AidpDocumentOperationItem[]; + failed_list: AidpDocumentOperationItem[]; +} + +type AidpOperationSummary = { + total: number; + success: number; + failed: number; +}; + +type AidpOperationResponse = { + summary: AidpOperationSummary; + success_list: TSuccess[]; + failed_list: TFailure[]; +}; + +const normalizeAidpOperationResponse = ( + result: Partial> +): AidpOperationResponse => { + const successList: TSuccess[] = Array.isArray(result.success_list) + ? result.success_list + : []; + const failedList: TFailure[] = Array.isArray(result.failed_list) + ? result.failed_list + : []; + + return { + summary: { + total: + typeof result.summary?.total === "number" + ? result.summary.total + : successList.length + failedList.length, + success: + typeof result.summary?.success === "number" + ? result.summary.success + : successList.length, + failed: + typeof result.summary?.failed === "number" + ? result.summary.failed + : failedList.length, + }, + success_list: successList, + failed_list: failedList, + }; +}; + export interface AidpModelItem { /** Display / identifier used for the model (sent to AIDP as ``vlm_model``). */ model_name: string; @@ -174,15 +243,21 @@ function buildUrl( class AidpKnowledgeService { /** - * List knowledge bases (paginated). + * List knowledge bases (paginated), optionally filtered by name. + * + * `keyword` is forwarded to the backend, which passes it on to AIDP for + * server-side filtering. A blank keyword is omitted from the query string + * entirely so an unfiltered call produces the same request as before. */ async listKbs( page: number = 1, - pageSize: number = 10 + pageSize: number = 10, + keyword?: string ): Promise { const url = buildUrl(API_ENDPOINTS.aidpMgmt.knowledgeBases, { page, page_size: pageSize, + keyword: keyword?.trim() || undefined, }); const response = await fetchWithErrorHandling(url, { @@ -346,31 +421,10 @@ class AidpKnowledgeService { } const result = (await response.json()) as Partial; - const successList = Array.isArray(result.success_list) - ? result.success_list - : []; - const failedList = Array.isArray(result.failed_list) - ? result.failed_list - : []; - - return { - summary: { - total: - typeof result.summary?.total === "number" - ? result.summary.total - : successList.length + failedList.length, - success: - typeof result.summary?.success === "number" - ? result.summary.success - : successList.length, - failed: - typeof result.summary?.failed === "number" - ? result.summary.failed - : failedList.length, - }, - success_list: successList, - failed_list: failedList, - }; + return normalizeAidpOperationResponse< + AidpUploadSuccessItem, + AidpUploadFailedItem + >(result); } /** @@ -451,8 +505,52 @@ class AidpKnowledgeService { typeof result.total_reliable === "boolean" ? result.total_reliable : typeof result.total_count === "number", + processing_count: + typeof result.processing_count === "number" + ? result.processing_count + : undefined, }; } + + /** + * Remove one document from an AIDP knowledge base. + * The AIDP API accepts an array, so the single-document UI sends one item. + */ + async removeDoc( + id: string, + fileUuid: string + ): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.removeKbDocuments(id), {}); + const response = await fetchWithErrorHandling(url, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ file_uuids: [fileUuid] }), + }); + const result = + (await response.json()) as Partial; + return normalizeAidpOperationResponse< + AidpDocumentOperationItem, + AidpDocumentOperationItem + >(result); + } + + /** + * Download one document through the AIDP management backend. + */ + async downloadDoc(id: string, fileUuid: string): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.downloadKbDocument(id), {}); + return fetchWithErrorHandling(url, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ file_uuid: fileUuid }), + }); + } } const aidpKnowledgeService = new AidpKnowledgeService(); diff --git a/frontend/ext_components/aidp/services/aidpUploadUtils.ts b/frontend/ext_components/aidp/services/aidpUploadUtils.ts new file mode 100644 index 0000000000..98de81dfb3 --- /dev/null +++ b/frontend/ext_components/aidp/services/aidpUploadUtils.ts @@ -0,0 +1,15 @@ +import type { AidpUploadFailedItem } from "./aidpKnowledgeService"; + +export const getAidpUploadFailureDetails = ( + failedList: AidpUploadFailedItem[], + language: string, + fallbackMessage: string +) => { + const isChinese = language.startsWith("zh"); + return failedList.map((item) => { + const reason = isChinese + ? item.reason_zh || item.reason_en + : item.reason_en || item.reason_zh; + return `${item.file_name}: ${reason || fallbackMessage}`; + }); +}; diff --git a/frontend/features/humanInteraction/useHumanInteractionController.ts b/frontend/features/humanInteraction/useHumanInteractionController.ts index fd5e0aa1f4..9c45849c4c 100644 --- a/frontend/features/humanInteraction/useHumanInteractionController.ts +++ b/frontend/features/humanInteraction/useHumanInteractionController.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { API_BASE_URL } from "@/services/api"; import { humanInteractionClient, type HumanDecision, @@ -18,6 +19,13 @@ const ACTIVE_STATUSES = new Set([ "RECOVERY_REQUIRED", ]); const STREAM_RECONNECT_STATUSES = new Set(["READY", "RUNNING"]); +const TERMINAL_STATUSES = new Set([ + "COMPLETED", + "FAILED", + "STOPPED", + "EXPIRED", + "RECOVERY_REQUIRED", +]); export interface HumanInteractionController { available: boolean; @@ -59,8 +67,16 @@ export function useHumanInteractionController({ new Map() ); const onEnabledChangeRef = useRef(onEnabledChange); + // Rate-limit snapshots: dedupe in-flight callers and enforce a 3s min interval. + const refreshInFlight = useRef(false); + const lastSnapshotAt = useRef(0); + const MIN_SNAPSHOT_INTERVAL_MS = 3000; + // Mirror of the latest `run` state; keeps `refresh` dependencies stable. + const runRef = useRef(null); activeConversation.current = conversationId; + const [activeRunId, setActiveRunId] = useState(null); + useEffect(() => { onEnabledChangeRef.current = onEnabledChange; }, [onEnabledChange]); @@ -84,11 +100,24 @@ export function useHumanInteractionController({ const refresh = useCallback(async () => { const requestedConversation = conversationId; - const sequence = ++refreshSequence.current; if (!conversationId) { + runRef.current = null; setRun(null); + setActiveRunId(null); return null; } + // Guard 1: coalesce while a snapshot is in flight. + if (refreshInFlight.current) { + return runRef.current; + } + // Guard 2: respect the minimum snapshot interval. + const now = Date.now(); + if (now - lastSnapshotAt.current < MIN_SNAPSHOT_INTERVAL_MS) { + return runRef.current; + } + refreshInFlight.current = true; + lastSnapshotAt.current = now; + const sequence = ++refreshSequence.current; try { const value = await humanInteractionClient.conversation(conversationId); if ( @@ -97,10 +126,13 @@ export function useHumanInteractionController({ ) { return null; } + runRef.current = value; setRun(value); setError(""); - if (!value || !STREAM_RECONNECT_STATUSES.has(value.status)) { - resume.current = null; + if (value) { + if (!STREAM_RECONNECT_STATUSES.has(value.status)) { + resume.current = null; + } } return value; } catch (cause) { @@ -112,29 +144,107 @@ export function useHumanInteractionController({ } setError(cause instanceof Error ? cause.message : String(cause)); return null; + } finally { + refreshInFlight.current = false; } }, [conversationId]); + /** + * Discovery: one-shot snapshots at conversation change and agent pause. + * After a snapshot reveals a run, the SSE subscription below keeps it live. + */ + useEffect(() => { + runRef.current = null; setRun(null); setError(""); resume.current = null; decisions.current.clear(); + setActiveRunId(null); if (!available || !conversationId) return; + void refresh(); + // One-shot fire on conversation change; do not re-run when refresh changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [available, conversationId]); - let active = true; - let timer: ReturnType; - const poll = async () => { - if (!active) return; - await refresh(); - if (active) timer = setTimeout(poll, 1500); + useEffect(() => { + // Agent stream paused → a HITL run may have just been created (ask_user). + if (isRunning || !available || !conversationId) return; + void refresh(); + }, [isRunning, available, conversationId, refresh]); + + // Retry tick for SSE resubscribe after transient network disconnects; + // terminal closes must not trigger a reconnect. + const [retryTick, setRetryTick] = useState(0); + + /** + * SSE subscription to the run events stream; every event triggers one + * rate-limited refresh(). Native auto-reconnect handles transient drops. + */ + + useEffect(() => { + if (!activeRunId) return; + + let tornDown = false; + let stoppedByUs = false; + const url = `${API_BASE_URL}/agent/human-interactions/${activeRunId}/events?after_event=${run?.event_seq ?? 0}`; + const es = new EventSource(url); + + es.onmessage = (ev) => { + if (tornDown || stoppedByUs) return; + if (!ev.data) return; + // Close proactively on terminal status so EventSource stops + // auto-reconnecting against a run that already finished. + try { + const parsed = JSON.parse(ev.data); + if ( + parsed.type === "human_run" && + parsed.content && + typeof parsed.content === "object" && + TERMINAL_STATUSES.has(parsed.content.status) + ) { + stoppedByUs = true; + es.close(); + void refresh(); + return; + } + } catch { + // Not JSON — refresh without parsing. + } + void refresh(); + }; + + es.onerror = () => { + if (tornDown || stoppedByUs) return; + // Let native auto-reconnect handle transient drops; fall back to a + // manual resubscribe once the browser gives up and the run is alive. + void refresh().then((latest) => { + if (tornDown || stoppedByUs) return; + if (!latest || TERMINAL_STATUSES.has(latest.status)) return; + es.close(); + stoppedByUs = true; + setTimeout(() => { + if (!tornDown) setRetryTick((c) => c + 1); + }, 2000); + }); }; - void poll(); + return () => { - active = false; - clearTimeout(timer); + tornDown = true; + stoppedByUs = true; + es.close(); }; - }, [available, conversationId, refresh]); + }, [activeRunId, retryTick]); + + // Subscribe to a discovered run's events stream; null tears it down. + + useEffect(() => { + if (!run) { + setActiveRunId((prev) => (prev ? null : prev)); + return; + } + setActiveRunId((prev) => (prev !== run.run_id ? run.run_id : prev)); + }, [run]); useEffect(() => { if (isRunning || !resume.current) return; @@ -168,6 +278,7 @@ export function useHumanInteractionController({ action ); refreshSequence.current += 1; + runRef.current = nextRun; setRun(nextRun); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -210,13 +321,15 @@ export function useHumanInteractionController({ runId: currentRun.run_id, after: currentRun.event_seq, }; - setRun({ + const nextRun = { ...currentRun, - status: "READY", + status: "READY" as const, requests: currentRun.requests.filter( (request) => request.request_id !== item.request_id ), - }); + }; + runRef.current = nextRun; + setRun(nextRun); } } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); diff --git a/frontend/lib/aidpDocumentStatus.ts b/frontend/lib/aidpDocumentStatus.ts new file mode 100644 index 0000000000..2cff52e8a4 --- /dev/null +++ b/frontend/lib/aidpDocumentStatus.ts @@ -0,0 +1,128 @@ +/** + * AIDP knowledge-file status helpers. + * + * Intentionally dependency-free (no imports) so the behaviour can be unit + * tested directly with `node --test`, like the other standalone helpers under + * `lib/`. + * + * Background: AIDP accepts an upload before it has chunked/embedded/indexed the + * file, and the file may not show up in the history directory for a moment + * after that. The list therefore has to keep refreshing itself after an upload + * until every uploaded file is listed with a terminal status — that is what + * `findPendingUploadIds` decides. + */ + +/** Processing statuses AIDP reports for a knowledge file. */ +export const AIDP_DOCUMENT_STATUS = { + UPLOADING: "UPLOADING", + PROCESSING: "PROCESSING", + EXTRACTING: "EXTRACTING", + COMPLETED: "COMPLETED", + FAILED: "FAILED", +} as const; + +/** + * Terminal statuses: the file will not change again, so it is pointless to keep + * polling for it. + */ +export const AIDP_DOC_TERMINAL_STATUSES: readonly string[] = [ + AIDP_DOCUMENT_STATUS.COMPLETED, + AIDP_DOCUMENT_STATUS.FAILED, +]; + +/** + * In-progress statuses, in the order AIDP walks through them: the file is being + * uploaded, then chunked/embedded, then its content is extracted. Every one of + * them keeps the status column live and must keep the list polling. + */ +export const AIDP_DOC_IN_PROGRESS_STATUSES: readonly string[] = [ + AIDP_DOCUMENT_STATUS.UPLOADING, + AIDP_DOCUMENT_STATUS.PROCESSING, + AIDP_DOCUMENT_STATUS.EXTRACTING, +]; + +/** + * Polling interval of the AIDP document list while work is outstanding. + * Ingestion takes seconds to minutes, so ten seconds keeps the status column + * responsive without hammering the backend. + */ +export const AIDP_DOC_STATUS_POLL_MS = 10000; + +/** + * Upper bound on how long the list keeps refreshing for files the user just + * uploaded. Guards against polling forever when AIDP silently drops an upload + * (or never reports the file through the history endpoint). + */ +export const AIDP_DOC_UPLOAD_WATCH_TIMEOUT_MS = 5 * 60 * 1000; + +/** Normalize a status for comparison; AIDP capitalization is not guaranteed. */ +export const normalizeAidpDocStatus = (status?: string): string => + (status || "").trim().toUpperCase(); + +/** + * Whether the status means "still on its way in". + * + * Any status AIDP has not told us is terminal counts as in progress, so a new + * upstream state keeps the column live instead of freezing the list. + */ +export const isAidpDocProcessing = (status?: string): boolean => { + const normalized = normalizeAidpDocStatus(status); + if (!normalized) return false; + return !isAidpDocTerminal(normalized); +}; + +/** Whether the status means "finished, one way or the other". */ +export const isAidpDocTerminal = (status?: string): boolean => + AIDP_DOC_TERMINAL_STATUSES.includes(normalizeAidpDocStatus(status)); + +/** Structural view of a listed document, so this module stays import-free. */ +export interface AidpDocumentStatusView { + file_ino_no?: string | number | null; + status?: string; +} + +/** Structural view of an entry of the AIDP upload response `success_list`. */ +export interface AidpUploadedFileView { + file_ino_no?: string | number | null; +} + +/** + * Collect the identities of the files AIDP accepted in an upload response. + * + * Entries without a usable `file_ino_no` are skipped rather than coerced into + * the literal string `"undefined"`, which would never match a listed document + * and would therefore keep the watch alive until it times out. + */ +export const collectUploadedFileIds = ( + successList?: readonly AidpUploadedFileView[] +): string[] => { + const ids: string[] = []; + for (const item of successList ?? []) { + const raw = item?.file_ino_no; + if (raw === undefined || raw === null || raw === "") continue; + ids.push(String(raw)); + } + return ids; +}; + +/** + * Which of the uploaded files are still unresolved. + * + * A file counts as resolved only once it appears in the list with a terminal + * status. A file that is not listed at all stays pending deliberately: right + * after an upload AIDP may not report it yet, and treating "not listed" as done + * would stop the refresh exactly when the list is still stale — the case where + * the user cannot tell whether the upload worked. + */ +export const findPendingUploadIds = ( + uploadedIds: readonly string[], + documents: readonly AidpDocumentStatusView[] +): string[] => { + if (uploadedIds.length === 0) return []; + const resolved = new Set( + documents + .filter((doc) => isAidpDocTerminal(doc.status)) + .map((doc) => String(doc.file_ino_no)) + ); + return uploadedIds.filter((id) => !resolved.has(String(id))); +}; diff --git a/frontend/lib/reasoningAccumulator.ts b/frontend/lib/reasoningAccumulator.ts index 95c80f8936..16de121033 100644 --- a/frontend/lib/reasoningAccumulator.ts +++ b/frontend/lib/reasoningAccumulator.ts @@ -7,6 +7,7 @@ interface ReasoningPart { /** Keep one reasoning block in its original position until a model/tool boundary. */ export function createReasoningAccumulator(parts: unknown[]) { let current: ReasoningPart | null = null; + const attempts = new Map(); const replace = (next: ReasoningPart) => { const index = current ? parts.indexOf(current) : -1; @@ -29,5 +30,35 @@ export function createReasoningAccumulator(parts: unknown[]) { replace({ ...current, status: { type: "done" } }); current = null; }, + beginAttempt(attemptId: string) { + attempts.set(attemptId, { + hadCurrent: current !== null, + text: current?.text ?? "", + }); + }, + rollbackAttempt(attemptId: string) { + const checkpoint = attempts.get(attemptId); + if (!checkpoint) return; + attempts.delete(attemptId); + if (checkpoint.hadCurrent) { + if (current) { + const index = parts.indexOf(current); + if (index < 0) return; + const restored = { + ...current, + text: checkpoint.text, + }; + parts[index] = restored; + current = restored; + } + } else if (current) { + const index = parts.indexOf(current); + if (index >= 0) parts.splice(index, 1); + current = null; + } + }, + commitAttempt(attemptId: string) { + attempts.delete(attemptId); + }, }; } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index fc1c438c82..81681aaa6e 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -844,7 +844,37 @@ "knowledgeBase.error.syncFailed": "Failed to sync DataMate knowledge bases", "knowledgeBase.message.testingConnection": "Testing connection...", "knowledgeBase.message.testingSync": "Syncing knowledge bases...", + "knowledgeBase.page.title": "Knowledge Base", + "knowledgeBase.page.description": "Create, manage, and maintain your team's knowledge assets", + "knowledgeBase.page.all": "All Knowledge Bases", + "knowledgeBase.page.count": "{{count}} total", + "knowledgeBase.page.back": "Back to knowledge bases", + "knowledgeBase.create.subtitle": "Configure basic information and adjust it later at any time", + "knowledgeBase.create.field.name": "Knowledge base name", + "knowledgeBase.create.field.description": "Description", + "knowledgeBase.create.optional": "(Optional)", + "knowledgeBase.create.namePlaceholder": "e.g. Product Knowledge Hub", + "knowledgeBase.create.descriptionPlaceholder": "Briefly describe what this knowledge base contains", + "knowledgeBase.create.advancedSettings": "Advanced settings", + "knowledgeBase.create.submit": "Create and enter", + "knowledgeBase.create.field.embeddingModel": "Embedding model", + "knowledgeBase.create.field.groups": "User groups", + "knowledgeBase.create.field.permission": "Group permission", + "knowledgeBase.create.field.preserve": "Document copy", + "knowledgeBase.create.field.quota": "Storage quota", + "knowledgeBase.create.uploadTitle": "Upload documents to build your knowledge base", "knowledgeBase.list.title": "Knowledge Base List", + "knowledgeBase.personalCapacity.title": "Personal knowledge base capacity", + "knowledgeBase.personalCapacity.withQuota": "Used {{used}} / Total {{quota}}", + "knowledgeBase.personalCapacity.unlimited": "Used {{used}} / Unlimited", + "knowledgeBase.personalCapacity.available": "Available", + "knowledgeBase.personalCapacity.total": "Total capacity", + "knowledgeBase.personalCapacity.unlimitedValue": "Unlimited", + "knowledgeBase.personalCapacity.loadFailed": "Personal knowledge base capacity is temporarily unavailable", + "knowledgeBase.capacity.title": "Storage capacity", + "knowledgeBase.capacity.available": "Available", + "knowledgeBase.capacity.total": "Total capacity", + "knowledgeBase.capacity.unlimited": "Unlimited", "knowledgeBase.button.create": "Create", "knowledgeBase.button.sync": "Sync", "knowledgeBase.button.syncDataMate": "Sync DataMate Knowledge Bases", @@ -867,6 +897,11 @@ "knowledgeBase.search.placeholder": "Search knowledge base name", "knowledgeBase.filter.source.placeholder": "Filter by source", "knowledgeBase.filter.model.placeholder": "Filter by model", + "knowledgeBase.filter.title": "Filter knowledge bases", + "knowledgeBase.filter.button": "Filter", + "knowledgeBase.card.create": "Create knowledge base", + "knowledgeBase.card.createDescription": "Upload documents to build a knowledge asset", + "knowledgeBase.tag.updatedAt": "Updated {{date}}", "knowledgeBase.filter.clear": "Clear filters", "knowledgeBase.source.nexent": "{productName}", "knowledgeBase.source.datamate": "DataMate", @@ -963,6 +998,7 @@ "document.hint.uploadToCreate": "Please select files to upload to complete knowledge base creation", "document.hint.noDocuments": "No documents in this knowledge base, please upload documents", "document.table.header.name": "Document Name", + "document.table.header.tags": "Tags", "document.table.header.status": "Status", "document.table.header.size": "Size", "document.table.header.date": "Upload Date", @@ -4001,13 +4037,29 @@ "aidpKnowledge.fetchDocsFailed": "Failed to fetch documents", "aidpKnowledge.docFileName": "File Name", "aidpKnowledge.docType": "Type", + "aidpKnowledge.docStatus": "Status", + "aidpKnowledge.docStatusUploading": "Uploading", + "aidpKnowledge.docStatusProcessing": "Processing", + "aidpKnowledge.docStatusExtracting": "Extracting", + "aidpKnowledge.docStatusCompleted": "Completed", + "aidpKnowledge.docStatusFailed": "Failed", "aidpKnowledge.docSize": "Size", "aidpKnowledge.docCreatedAt": "Created At", + "aidpKnowledge.docActions": "Actions", + "aidpKnowledge.download": "Download", + "aidpKnowledge.delete": "Delete", + "aidpKnowledge.downloadSuccess": "File download started", + "aidpKnowledge.downloadFailed": "Failed to download file", + "aidpKnowledge.confirmDeleteDocTitle": "Delete document", + "aidpKnowledge.confirmDeleteDocContent": "Are you sure you want to delete this document? This cannot be undone.", + "aidpKnowledge.deleteDocSuccess": "Document deleted successfully", + "aidpKnowledge.deleteDocFailed": "Failed to delete document", "aidpKnowledge.noDocuments": "No documents yet", "aidpKnowledge.loadingDocs": "Loading documents...", "aidpKnowledge.uploadSuccess": "{{count}} document(s) uploaded successfully", "aidpKnowledge.uploadPartial": "{{success}} uploaded, {{failed}} failed", "aidpKnowledge.uploadFailed": "Upload failed", + "aidpKnowledge.uploadDuplicateFile": "\"{{fileName}}\" already exists. Please do not upload it again.", "aidpKnowledge.uploading": "Uploading...", "aidpKnowledge.uploadHint": "Click or drag files here to upload", "aidpKnowledge.uploadHintDetail": "Text (txt/json/markdown), Web (html), Docs (pdf/docx/doc/ppt/pptx), Sheets (xlsx/xls/csv), Images (png/jpeg/jpg/bmp)", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index b5f66b9d19..fe9c0613fd 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -814,7 +814,37 @@ "knowledgeBase.error.syncFailed": "同步 DataMate 知识库失败", "knowledgeBase.message.testingConnection": "正在测试连接...", "knowledgeBase.message.testingSync": "正在同步知识库...", + "knowledgeBase.page.title": "知识库", + "knowledgeBase.page.description": "创建、管理并维护团队的知识资产", + "knowledgeBase.page.all": "全部知识库", + "knowledgeBase.page.count": "共 {{count}} 个", + "knowledgeBase.page.back": "返回知识库", + "knowledgeBase.create.subtitle": "配置基本信息,后续可随时调整", + "knowledgeBase.create.field.name": "知识库名称", + "knowledgeBase.create.field.description": "描述", + "knowledgeBase.create.optional": "(选填)", + "knowledgeBase.create.namePlaceholder": "例如:产品知识中心", + "knowledgeBase.create.descriptionPlaceholder": "简要说明这个知识库包含什么内容", + "knowledgeBase.create.advancedSettings": "高级设置", + "knowledgeBase.create.submit": "创建并进入", + "knowledgeBase.create.field.embeddingModel": "向量模型", + "knowledgeBase.create.field.groups": "所属用户组", + "knowledgeBase.create.field.permission": "组内权限", + "knowledgeBase.create.field.preserve": "文档副本", + "knowledgeBase.create.field.quota": "存储配额", + "knowledgeBase.create.uploadTitle": "上传文档,构建知识库", "knowledgeBase.list.title": "知识库列表", + "knowledgeBase.personalCapacity.title": "个人知识库容量", + "knowledgeBase.personalCapacity.withQuota": "已用 {{used}} / 总容量 {{quota}}", + "knowledgeBase.personalCapacity.unlimited": "已用 {{used}} / 无限制", + "knowledgeBase.personalCapacity.available": "可用容量", + "knowledgeBase.personalCapacity.total": "总容量", + "knowledgeBase.personalCapacity.unlimitedValue": "无限制", + "knowledgeBase.personalCapacity.loadFailed": "个人知识库容量暂时无法加载", + "knowledgeBase.capacity.title": "存储容量", + "knowledgeBase.capacity.available": "可用容量", + "knowledgeBase.capacity.total": "总容量", + "knowledgeBase.capacity.unlimited": "无限制", "knowledgeBase.button.create": "创建", "knowledgeBase.button.sync": "同步", "knowledgeBase.button.syncDataMate": "同步DataMate知识库", @@ -837,6 +867,11 @@ "knowledgeBase.search.placeholder": "搜索知识库名称", "knowledgeBase.filter.source.placeholder": "筛选来源", "knowledgeBase.filter.model.placeholder": "筛选模型", + "knowledgeBase.filter.title": "筛选知识库", + "knowledgeBase.filter.button": "筛选", + "knowledgeBase.card.create": "新建知识库", + "knowledgeBase.card.createDescription": "上传文档,构建专属知识资产", + "knowledgeBase.tag.updatedAt": "更新于{{date}}", "knowledgeBase.source.nexent": "{productName}", "knowledgeBase.source.datamate": "DataMate", "knowledgeBase.source.dify": "Dify", @@ -931,6 +966,7 @@ "document.hint.uploadToCreate": "请选择文件上传以完成知识库创建", "document.hint.noDocuments": "该知识库中暂无文档,请上传文档", "document.table.header.name": "文档名称", + "document.table.header.tags": "标签", "document.table.header.status": "状态", "document.table.header.size": "大小", "document.table.header.date": "上传日期", @@ -3716,13 +3752,29 @@ "aidpKnowledge.fetchDocsFailed": "获取文档列表失败", "aidpKnowledge.docFileName": "文件名", "aidpKnowledge.docType": "类型", + "aidpKnowledge.docStatus": "状态", + "aidpKnowledge.docStatusUploading": "上传中", + "aidpKnowledge.docStatusProcessing": "处理中", + "aidpKnowledge.docStatusExtracting": "提取中", + "aidpKnowledge.docStatusCompleted": "已完成", + "aidpKnowledge.docStatusFailed": "失败", "aidpKnowledge.docSize": "大小", "aidpKnowledge.docCreatedAt": "创建时间", + "aidpKnowledge.docActions": "操作", + "aidpKnowledge.download": "下载", + "aidpKnowledge.delete": "删除", + "aidpKnowledge.downloadSuccess": "文件下载已开始", + "aidpKnowledge.downloadFailed": "文件下载失败", + "aidpKnowledge.confirmDeleteDocTitle": "删除文档", + "aidpKnowledge.confirmDeleteDocContent": "确定删除该文档吗?此操作无法撤销。", + "aidpKnowledge.deleteDocSuccess": "文档删除成功", + "aidpKnowledge.deleteDocFailed": "文档删除失败", "aidpKnowledge.noDocuments": "暂无文档", "aidpKnowledge.loadingDocs": "正在加载文档...", "aidpKnowledge.uploadSuccess": "成功上传 {{count}} 个文档", "aidpKnowledge.uploadPartial": "{{success}} 个上传成功,{{failed}} 个失败", "aidpKnowledge.uploadFailed": "上传失败", + "aidpKnowledge.uploadDuplicateFile": "\"{{fileName}}\":文件已存在,请勿重复上传。", "aidpKnowledge.uploading": "正在上传...", "aidpKnowledge.uploadHint": "点击或拖拽文件到此处上传", "aidpKnowledge.uploadHintDetail": "支持文本(txt/json/markdown)、网页(html)、文档(pdf/docx/doc/ppt/pptx)、表格(xlsx/xls/csv)、图片(png/jpeg/jpg/bmp)", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index a6fa45e0cf..b1c1336288 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -377,6 +377,10 @@ export const API_ENDPOINTS = { kbDetail: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}`, kbDocuments: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents`, + removeKbDocuments: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/remove`, + downloadKbDocument: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/download`, models: `${API_BASE_URL}/aidp-mgmt/models`, /** PATCH endpoint for the per-KB in-group permission. */ kbPermission: (id: string) => diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 4a035aa238..820b54bb77 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -832,6 +832,9 @@ export const modelService = { temperature?: number; topP?: number; extraParams?: Record; + // Edit-dialog probes: when set, the backend falls back to the stored + // api_key for this model when apiKey is empty/placeholder. + modelId?: number; }, signal?: AbortSignal ): Promise => { @@ -841,6 +844,9 @@ export const modelService = { model_type: config.modelType, api_key: config.apiKey || "sk-no-api-key", base_url: config.baseUrl || "", + ...(config.modelId !== undefined + ? { probe_model_id: config.modelId } + : {}), ...(config.maxTokens !== undefined ? { max_tokens: config.maxTokens } : {}), diff --git a/frontend/tests/aidpDocumentStatus.test.ts b/frontend/tests/aidpDocumentStatus.test.ts new file mode 100644 index 0000000000..5f420c67f9 --- /dev/null +++ b/frontend/tests/aidpDocumentStatus.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AIDP_DOCUMENT_STATUS, + AIDP_DOC_IN_PROGRESS_STATUSES, + collectUploadedFileIds, + findPendingUploadIds, + isAidpDocProcessing, + isAidpDocTerminal, + normalizeAidpDocStatus, + // @ts-expect-error -- Node requires the extension for this standalone test. +} from "../lib/aidpDocumentStatus.ts"; + +test("normalizes status casing and surrounding whitespace", () => { + assert.equal(normalizeAidpDocStatus(" processing "), "PROCESSING"); + assert.equal(normalizeAidpDocStatus("Completed"), "COMPLETED"); + assert.equal(normalizeAidpDocStatus(undefined), ""); + assert.equal(normalizeAidpDocStatus(" "), ""); +}); + +test("treats a status without a terminal outcome as still processing", () => { + assert.equal(isAidpDocProcessing("PROCESSING"), true); + assert.equal(isAidpDocProcessing("processing"), true); + assert.equal(isAidpDocProcessing(AIDP_DOCUMENT_STATUS.COMPLETED), false); + assert.equal(isAidpDocProcessing(undefined), false); + + assert.equal(isAidpDocTerminal("COMPLETED"), true); + assert.equal(isAidpDocTerminal("failed"), true); + assert.equal(isAidpDocTerminal("PROCESSING"), false); + // An unknown status is not terminal: polling must not stop on a status the + // UI does not understand. + assert.equal(isAidpDocTerminal("QUEUED"), false); + assert.equal(isAidpDocTerminal(undefined), false); +}); + +test("keeps every upload stage out of the terminal set", () => { + assert.deepEqual(AIDP_DOC_IN_PROGRESS_STATUSES, [ + "UPLOADING", + "PROCESSING", + "EXTRACTING", + ]); + for (const status of AIDP_DOC_IN_PROGRESS_STATUSES) { + assert.equal( + isAidpDocTerminal(status), + false, + `${status} must not be terminal` + ); + assert.equal( + isAidpDocProcessing(status), + true, + `${status} must count as processing` + ); + } + // Case-insensitive, like every other status comparison. + assert.equal(isAidpDocProcessing("extracting"), true); + assert.equal(isAidpDocProcessing(" uploading "), true); +}); + +test("keeps an upload pending while AIDP has not listed it yet", () => { + // The reported bug: right after an upload the file is not in the list at all, + // so the refresh must keep running instead of stopping immediately. + assert.deepEqual(findPendingUploadIds(["file-1"], []), ["file-1"]); +}); + +test("keeps an upload pending while it is still processing", () => { + const documents = [{ file_ino_no: "file-1", status: "PROCESSING" }]; + assert.deepEqual(findPendingUploadIds(["file-1"], documents), ["file-1"]); +}); + +test("settles an upload once it reaches a terminal status", () => { + assert.deepEqual( + findPendingUploadIds( + ["file-1"], + [{ file_ino_no: "file-1", status: "COMPLETED" }] + ), + [] + ); + assert.deepEqual( + findPendingUploadIds( + ["file-1"], + [{ file_ino_no: "file-1", status: "FAILED" }] + ), + [] + ); +}); + +test("matches numeric upload ids against string document ids", () => { + // AIDP returns numbers from the upload endpoint and strings from the history + // listing, so a strict comparison would never settle the watch. The ids are + // read back through the same helper the components use. + const uploadedIds = collectUploadedFileIds([{ file_ino_no: 17001 }]); + assert.deepEqual(uploadedIds, ["17001"]); + assert.deepEqual( + findPendingUploadIds(uploadedIds, [ + { file_ino_no: "17001", status: "COMPLETED" }, + ]), + [] + ); +}); + +test("reports only the uploads that are still unresolved", () => { + const documents = [ + { file_ino_no: "file-1", status: "COMPLETED" }, + { file_ino_no: "file-2", status: "PROCESSING" }, + { file_ino_no: "file-other", status: "FAILED" }, + ]; + assert.deepEqual( + findPendingUploadIds(["file-1", "file-2", "file-3"], documents), + ["file-2", "file-3"] + ); +}); + +test("returns nothing to wait for when there was no upload", () => { + assert.deepEqual(findPendingUploadIds([], [{ file_ino_no: "file-1" }]), []); +}); + +test("collects uploaded file ids from the upload response", () => { + assert.deepEqual( + collectUploadedFileIds([{ file_ino_no: 17001 }, { file_ino_no: "17002" }]), + ["17001", "17002"] + ); +}); + +test("skips upload entries without a usable id", () => { + // A missing id must not become the literal string "undefined", which would + // never match a listed document and would keep the watch alive until timeout. + assert.deepEqual( + collectUploadedFileIds([ + {}, + { file_ino_no: undefined }, + { file_ino_no: "" }, + { file_ino_no: null }, + { file_ino_no: 17003 }, + ]), + ["17003"] + ); + assert.deepEqual(collectUploadedFileIds(undefined), []); + assert.deepEqual(collectUploadedFileIds([]), []); +}); diff --git a/frontend/tests/reasoningAccumulator.test.ts b/frontend/tests/reasoningAccumulator.test.ts index 6ed3a1a364..f36f67fb53 100644 --- a/frontend/tests/reasoningAccumulator.test.ts +++ b/frontend/tests/reasoningAccumulator.test.ts @@ -90,6 +90,49 @@ test("empty reasoning cannot move guidance received before the model starts", () ); }); +test("CMSR-003 rollback removes only the failed model attempt", () => { + const parts: unknown[] = []; + const reasoning = createReasoningAccumulator(parts); + reasoning.append("stable prefix"); + reasoning.beginAttempt("attempt-one"); + reasoning.append(" leaked partial"); + reasoning.rollbackAttempt("attempt-one"); + + assert.deepEqual(parts, [ + { + type: "reasoning", + text: "stable prefix", + status: { type: "running" }, + }, + ]); + + reasoning.beginAttempt("attempt-two"); + reasoning.append(" recovered"); + reasoning.commitAttempt("attempt-two"); + assert.equal((parts[0] as { text: string }).text, "stable prefix recovered"); +}); + +test("CMSR-003 rollback preserves interleaved sibling output", () => { + const parts: unknown[] = []; + const reasoning = createReasoningAccumulator(parts); + reasoning.append("parent prefix"); + reasoning.beginAttempt("parent-attempt"); + reasoning.append(" leaked parent token"); + + const sibling = { type: "reasoning", text: "sibling output" }; + parts.unshift(sibling); + reasoning.rollbackAttempt("parent-attempt"); + + assert.deepEqual(parts, [ + sibling, + { + type: "reasoning", + text: "parent prefix", + status: { type: "running" }, + }, + ]); +}); + test("a new model step starts below guidance even when no tool ran", () => { const parts: unknown[] = []; const reasoning = createReasoningAccumulator(parts); diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index acb9e1653d..086be61be2 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -25,6 +25,7 @@ from ...monitor import get_monitoring_manager +from ..model_errors import ModelInvocationTerminalError from ..utils.observer import MessageObserver, ProcessType from jinja2 import Template, StrictUndefined @@ -910,7 +911,11 @@ def rebuild_after_provider_overflow(): self.logger.log_markdown( content=model_output, title="MODEL OUTPUT", level=LogLevel.INFO) + except ModelInvocationTerminalError: + raise except Exception as e: + if self.stop_event.is_set(): + raise RunTerminated() from e raise AgentGenerationError( f"Error in generating model output:\n{e}", self.logger) from e @@ -1492,6 +1497,13 @@ def _run_stream( except StepSteered: interrupted = True continue + except ModelInvocationTerminalError: + # The model adapter has already exhausted its complete physical + # call budget (or classified the failure as non-retryable). + # Do not persist this incomplete step or let the ReAct loop + # turn it into a subsequent model invocation. + interrupted = True + raise except (AttemptSuspended, RecoveryRequired, RunTerminated): interrupted = True raise @@ -1703,6 +1715,8 @@ def rebuild_final_after_provider_overflow(): total_input_tokens = chat_message.token_usage.input_tokens total_output_tokens = chat_message.token_usage.output_tokens + except ModelInvocationTerminalError: + raise except Exception as e: # Fallback to error message if streaming fails model_output = f"Error in generating final LLM output: {e}" diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index 163c6fc20f..77746eef0e 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -21,6 +21,7 @@ from ...monitor import AgentRunMetadata, get_agent_monitoring_context, get_monitoring_manager from ..models.openai_llm import OpenAIModel +from ..model_errors import ModelInvocationTerminalError from ..tools import * # Used for tool creation, do not delete!!! from ..utils.constants import THINK_PREFIX_PATTERN, THINK_TAG_PATTERN from ..utils.observer import MessageObserver, ProcessType @@ -1177,6 +1178,15 @@ def agent_run_with_observer( if self.agent.stop_event.is_set(): observer.add_message(self.agent.agent_name, ProcessType.WARNING, "Agent execution interrupted by external stop signal") + except ModelInvocationTerminalError as e: + observer.add_message( + agent_name=self.agent.agent_name, + process_type=ProcessType.ERROR, + content=e.safe_message(getattr(observer, "lang", "en")), + error_code=e.error_code.value, + retryable=False, + ) + raise except Exception as e: observer.add_message(agent_name=self.agent.agent_name, process_type=ProcessType.ERROR, content=f"Error in interaction: {str(e)}") @@ -1380,7 +1390,7 @@ def _initialize_sandbox_workspaces(self) -> None: @staticmethod def _grant_sandbox_output_access(container: Any, workspace: Path) -> None: - """Allow the sandbox user to read and write the exact run workspace.""" + """Allow sandbox traversal of the user directory and writes in the run workspace.""" gid_result = container.exec_run(["id", "-g"]) gid_exit_code = getattr(gid_result, "exit_code", None) gid_output = getattr(gid_result, "output", b"") @@ -1394,7 +1404,10 @@ def _grant_sandbox_output_access(container: Any, workspace: Path) -> None: raise RuntimeError("Sandbox user returned an invalid group ID") workspace_dir = str(workspace) + workspace_parent_dir = str(workspace.parent) commands = ( + ["chgrp", sandbox_gid, workspace_parent_dir], + ["chmod", "g+xs", workspace_parent_dir], ["chgrp", "-R", sandbox_gid, workspace_dir], ["chmod", "-R", "g+rwX", workspace_dir], ["find", workspace_dir, "-type", "d", "-exec", "chmod", "g+s", "{}", "+"], diff --git a/sdk/nexent/core/agents/run_agent.py b/sdk/nexent/core/agents/run_agent.py index f3d5ea1e3d..2aa2670283 100644 --- a/sdk/nexent/core/agents/run_agent.py +++ b/sdk/nexent/core/agents/run_agent.py @@ -17,6 +17,7 @@ shutdown_fallback_thread_manager, ) from ..human_interaction.contracts import AttemptSuspended, RecoveryRequired, RunTerminated +from ..model_errors import ModelInvocationTerminalError from .agent_model import AgentRunInfo from .managed_mcp import ManagedMCPToolCollection from .nexent_agent import NexentAgent, ProcessType, cleanup_run_workspace @@ -363,6 +364,9 @@ def agent_run_thread(agent_run_info: AgentRunInfo): "Please start a new task." ) agent_run_info.observer.add_message("", ProcessType.ERROR, message) + except ModelInvocationTerminalError: + agent_run_info.attempt_outcome = "failed" + raise except Exception as e: agent_run_info.attempt_outcome = "failed" if "Couldn't connect to the MCP server" in str(e): diff --git a/sdk/nexent/core/gateway/modality/vlm/openai.py b/sdk/nexent/core/gateway/modality/vlm/openai.py index ddb4f55bc0..b4c0804d4f 100644 --- a/sdk/nexent/core/gateway/modality/vlm/openai.py +++ b/sdk/nexent/core/gateway/modality/vlm/openai.py @@ -224,7 +224,14 @@ async def check_connectivity(self) -> bool: """ if self._model is None: self._build_model() - module_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # Anchor on the nexent package root: the probe asset lives in + # nexent/assets/. A relative dirname chain silently broke when this + # module moved deeper into the package (3 levels up resolved to + # .../core/gateway instead of the package root), so the local probe + # image was never found and every check fell back to the public URL - + # which fails in offline deployments. + import nexent + module_dir = os.path.dirname(os.path.abspath(nexent.__file__)) test_image_path = os.path.join(module_dir, "assets", "git-flow.png") if os.path.exists(test_image_path): base64_image = self.encode_image(test_image_path) diff --git a/sdk/nexent/core/model_errors.py b/sdk/nexent/core/model_errors.py new file mode 100644 index 0000000000..eae0b61b61 --- /dev/null +++ b/sdk/nexent/core/model_errors.py @@ -0,0 +1,92 @@ +"""Typed terminal failures shared by model adapters and Agent boundaries. + +This module deliberately lives outside ``core.models`` so Agent modules can +depend on the error contract without importing every model implementation. +""" + +from __future__ import annotations + +from enum import Enum + + +class ModelErrorCode(str, Enum): + """Stable error categories exposed by the Agent stream boundary.""" + + RATE_LIMIT_EXHAUSTED = "model_rate_limit_exhausted" + SERVICE_UNAVAILABLE = "model_service_unavailable" + TIMEOUT = "model_timeout" + CONNECTION_ERROR = "model_connection_error" + AUTHENTICATION_ERROR = "model_authentication_error" + NOT_FOUND = "model_not_found" + INVALID_REQUEST = "model_invalid_request" + CONTEXT_OVERFLOW = "model_context_overflow" + EMPTY_RESPONSE_EXHAUSTED = "model_empty_response_exhausted" + UNKNOWN_ERROR = "model_unknown_error" + + +_SAFE_ERROR_MESSAGES = { + ModelErrorCode.RATE_LIMIT_EXHAUSTED: { + "en": "The model is receiving too many requests. Please try again later.", + "zh": "模型请求过于频繁,请稍后重试。", + }, + ModelErrorCode.SERVICE_UNAVAILABLE: { + "en": "The model service is temporarily unavailable. Please try again later.", + "zh": "模型服务暂时不可用,请稍后重试。", + }, + ModelErrorCode.TIMEOUT: { + "en": "The model request timed out. Please try again later.", + "zh": "模型请求超时,请稍后重试。", + }, + ModelErrorCode.CONNECTION_ERROR: { + "en": "The model service connection was interrupted. Please try again later.", + "zh": "模型服务连接中断,请稍后重试。", + }, + ModelErrorCode.AUTHENTICATION_ERROR: { + "en": "The model service credentials are invalid or unauthorized.", + "zh": "模型服务凭据无效或未获授权。", + }, + ModelErrorCode.NOT_FOUND: { + "en": "The selected model or model endpoint was not found.", + "zh": "未找到所选模型或模型服务地址。", + }, + ModelErrorCode.INVALID_REQUEST: { + "en": "The model request is invalid and cannot be retried.", + "zh": "模型请求无效,无法重试。", + }, + ModelErrorCode.CONTEXT_OVERFLOW: { + "en": "The conversation is too long for the selected model.", + "zh": "当前会话内容超出所选模型的上下文限制。", + }, + ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED: { + "en": "The model repeatedly returned an empty response. Please try again later.", + "zh": "模型连续返回空响应,请稍后重试。", + }, + ModelErrorCode.UNKNOWN_ERROR: { + "en": "The model request failed. Please try again later.", + "zh": "模型请求失败,请稍后重试。", + }, +} + + +class ModelInvocationTerminalError(RuntimeError): + """Terminal model failure that must not be repaired by another Agent step.""" + + def __init__( + self, + error_code: ModelErrorCode, + attempts: int, + *, + cause: BaseException | None = None, + ) -> None: + # Keep provider detail internal for logs and tracing. UI boundaries + # must use ``safe_message`` instead of ``str``. + super().__init__(str(cause) if cause is not None else error_code.value) + self.error_code = error_code + self.attempts = attempts + self.retryable = False + if cause is not None: + self.__cause__ = cause + + def safe_message(self, lang: str = "en") -> str: + messages = _SAFE_ERROR_MESSAGES[self.error_code] + return messages.get(lang, messages["en"]) diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index d15ae7cc95..295d02c6bd 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -11,9 +11,11 @@ import logging import threading import asyncio +import importlib import time import json import httpx +import uuid from typing import List, Optional, Dict, Any from openai.types.chat.chat_completion_message import ChatCompletionMessage @@ -43,6 +45,8 @@ from .retry import ( DEFAULT_MODEL_RETRY, ModelRetryConfig, + ModelErrorCode, + ModelInvocationTerminalError, classify_model_error, get_retry_after_seconds, ) @@ -74,6 +78,33 @@ class EmptyModelResponseError(RuntimeError): """Raised when a completed provider stream contains no user-visible content.""" +def _build_compatible_http_timeout( + default_http_client_type: type, + *, + connect: float, + read: float, + write: float, + pool: float, +): + """Build a timeout owned by the HTTP implementation used by OpenAI. + + OpenAI 3.x may use ``httpx2`` internally while Nexent still imports the + public ``httpx`` package for its own exception handling. Passing an + ``httpx.Timeout`` into an ``httpx2.Client`` nests incompatible timeout + objects and fails before the first provider request. Resolve the timeout + class from ``DefaultHttpxClient``'s public base class instead, falling + back to ``httpx`` for older OpenAI releases and test doubles. + """ + for base in getattr(default_http_client_type, "__mro__", ()): + module_root = getattr(base, "__module__", "").partition(".")[0] + if not module_root.startswith("httpx"): + continue + timeout_type = getattr(importlib.import_module(module_root), "Timeout", None) + if timeout_type is not None: + return timeout_type(connect=connect, read=read, write=write, pool=pool) + return httpx.Timeout(connect=connect, read=read, write=write, pool=pool) + + def _is_timeout_error(exc: BaseException) -> bool: """Return whether an exception chain represents a network or caller timeout.""" current: BaseException | None = exc @@ -188,13 +219,19 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, # Keep every streaming HTTP phase finite. Callers can still inject a # custom client through client_kwargs when they own its lifecycle. client_kwargs = kwargs.get("client_kwargs", {}) + # The Agent retry budget counts physical provider requests. Disable + # the OpenAI client's hidden transport retries by default so one + # adapter attempt cannot fan out into multiple uncounted HTTP calls. + # A fully injected client remains under its caller's ownership, but + # clients constructed here must never exceed this adapter's budget. + client_kwargs["max_retries"] = 0 if "http_client" not in client_kwargs: from openai import DefaultHttpxClient - from openai._base_client import httpx2 http_client = DefaultHttpxClient( verify=ssl_verify, - timeout=httpx2.Timeout( + timeout=_build_compatible_http_timeout( + DefaultHttpxClient, connect=connect_timeout_seconds, read=self.read_timeout_seconds, write=write_timeout_seconds, @@ -240,6 +277,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List response_format: dict[str, str] | None = None, tools_to_call_from: Optional[List[Tool]] = None, _token_tracker=None, context_budget_snapshot: Optional[ContextBudgetSnapshot] = None, context_rebuild=None, _overflow_recovery_ordinal: int = 0, + _model_attempts_used: int = 0, **kwargs, ) -> ChatMessage: _monitoring_operation.set("chat_completion") @@ -282,6 +320,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List context_budget_snapshot=context_budget_snapshot, context_rebuild=context_rebuild, _overflow_recovery_ordinal=_overflow_recovery_ordinal, + _model_attempts_used=_model_attempts_used, **kwargs, ) @@ -413,13 +452,22 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List } ) - for attempt in range(1, self.retry_config.max_attempts + 1): + for attempt in range(_model_attempts_used + 1, self.retry_config.max_attempts + 1): first_token_received = False if self.stop_event.is_set(): if token_tracker: self._monitoring.add_span_event("model_stopped", { "reason": "stop_event_set"}) raise RuntimeError(STOP_EVENT_INTERRUPTED_MESSAGE) + attempt_id = uuid.uuid4().hex + begin_attempt = getattr(self.observer, "begin_model_attempt", None) + if callable(begin_attempt): + begin_attempt(attempt_id, attempt) + self._monitoring.add_span_event("model_attempt_begin", { + "attempt_id": attempt_id, + "attempt": attempt, + "max_attempts": self.retry_config.max_attempts, + }) current_request = None stream_token = None close_stream_once = None @@ -673,6 +721,13 @@ def _close_stream_once(): ) message.raw = current_request message.role = MessageRole.ASSISTANT + commit_attempt = getattr(self.observer, "commit_model_attempt", None) + if callable(commit_attempt): + commit_attempt(attempt_id, attempt) + self._monitoring.add_span_event("model_attempt_commit", { + "attempt_id": attempt_id, + "attempt": attempt, + }) return message except Exception as e: @@ -681,22 +736,29 @@ def _close_stream_once(): e).__name__, "error_message": str(e)}) raise e - except EmptyModelResponseError: - # Some reasoning-capable OpenAI-compatible providers - # occasionally finish with ``stop`` after emitting only - # reasoning chunks. Retry once inside the model adapter so an - # otherwise transient malformed stream does not consume a - # visible agent step. A ``length`` finish is deterministic - # truncation and must still surface immediately. - empty_retry_limit = min(self.retry_config.max_attempts, 2) - if self.last_finish_reason not in (None, "stop") or attempt >= empty_retry_limit: - raise + except EmptyModelResponseError as empty_error: + rollback_attempt = getattr(self.observer, "rollback_model_attempt", None) + if callable(rollback_attempt): + rollback_attempt(attempt_id, attempt) + self._monitoring.add_span_event("model_attempt_rollback", { + "attempt_id": attempt_id, + "attempt": attempt, + "reason": "empty_response", + }) + # Empty ``stop`` responses share the normal model attempt + # budget. Deterministic truncation (``length``) fails fast. + if self.last_finish_reason not in (None, "stop") or attempt >= self.retry_config.max_attempts: + raise ModelInvocationTerminalError( + ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED, + attempt, + cause=empty_error, + ) from empty_error backoff = self.retry_config.calculate_backoff(attempt) logger.warning( "event=retry_empty_model_response attempt=%d/%d finish_reason=%s " "retrying_after_seconds=%.2f", attempt, - empty_retry_limit, + self.retry_config.max_attempts, self.last_finish_reason, backoff, ) @@ -706,6 +768,14 @@ def _close_stream_once(): self.stop_event.wait(backoff) continue except Exception as e: + rollback_attempt = getattr(self.observer, "rollback_model_attempt", None) + if callable(rollback_attempt): + rollback_attempt(attempt_id, attempt) + self._monitoring.add_span_event("model_attempt_rollback", { + "attempt_id": attempt_id, + "attempt": attempt, + "error_type": type(e).__name__, + }) if self.stop_event.is_set() or self.cancellation_scope.cancelled: raise RuntimeError(STOP_EVENT_INTERRUPTED_MESSAGE) from e if isinstance(e, ModelConcurrencyExceeded): @@ -717,13 +787,29 @@ def _close_stream_once(): }) if is_provider_context_overflow(e): if first_token_received or context_rebuild is None: - raise ProviderContextOverflowRetryUnsafe( + overflow_error = ProviderContextOverflowRetryUnsafe( "Provider context overflow cannot be safely rebuilt: " f"{e}" + ) + raise ModelInvocationTerminalError( + ModelErrorCode.CONTEXT_OVERFLOW, + attempt, + cause=overflow_error, + ) from e + if attempt >= self.retry_config.max_attempts: + raise ModelInvocationTerminalError( + ModelErrorCode.CONTEXT_OVERFLOW, + attempt, + cause=e, ) from e if _overflow_recovery_ordinal >= 2: - raise ProviderContextOverflowRetryExhausted( + overflow_error = ProviderContextOverflowRetryExhausted( "Provider context overflow persisted after two recovery dispatches" + ) + raise ModelInvocationTerminalError( + ModelErrorCode.CONTEXT_OVERFLOW, + attempt, + cause=overflow_error, ) from e rebuilt = context_rebuild() rebuilt_messages = getattr(rebuilt, "messages", rebuilt) @@ -752,6 +838,7 @@ def _close_stream_once(): context_budget_snapshot=trusted_budget_snapshot, context_rebuild=context_rebuild, _overflow_recovery_ordinal=_overflow_recovery_ordinal + 1, + _model_attempts_used=attempt, **kwargs, ) is_timeout = _is_timeout_error(e) @@ -769,15 +856,28 @@ def _close_stream_once(): received_chunk_count, type(e).__name__, ) - if classify_model_error(e) != "retryable": - raise + classification = classify_model_error(e) + if not classification.retryable: + raise ModelInvocationTerminalError( + classification.error_code, + attempt, + cause=e, + ) from e if attempt >= self.retry_config.max_attempts: if not is_timeout: - logging.exception( - "Model call failed after %d attempts: %s", - attempt, str(e), + logger.error( + "event=model_retry_exhausted attempt=%d/%d " + "error_type=%s error_code=%s", + attempt, + self.retry_config.max_attempts, + type(e).__name__, + classification.error_code.value, ) - raise + raise ModelInvocationTerminalError( + classification.error_code, + attempt, + cause=e, + ) from e backoff = self.retry_config.calculate_backoff(attempt) retry_after = get_retry_after_seconds(e) if retry_after is not None: @@ -796,9 +896,13 @@ def _close_stream_once(): ) else: logger.warning( - "Model call attempt %d/%d failed with retryable error (%s); " - "retrying after %.2fs", - attempt, self.retry_config.max_attempts, str(e), backoff, + "event=model_retry attempt=%d/%d error_type=%s " + "error_code=%s retrying_after_seconds=%.2f", + attempt, + self.retry_config.max_attempts, + type(e).__name__, + classification.error_code.value, + backoff, ) self.last_retry_count = attempt if self.stop_event.is_set(): diff --git a/sdk/nexent/core/models/openai_vlm.py b/sdk/nexent/core/models/openai_vlm.py index 983d24fce9..44233a563e 100644 --- a/sdk/nexent/core/models/openai_vlm.py +++ b/sdk/nexent/core/models/openai_vlm.py @@ -45,8 +45,13 @@ async def check_connectivity(self) -> bool: Returns: bool: True if the model responds successfully, otherwise False. """ - # Use local test image from images folder - use absolute path based on module location - module_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # Use local test image from images folder - anchor on the nexent + # package root (asset lives in nexent/assets/). A relative dirname + # chain worked here only by coincidence of module depth and broke in + # the gateway copy of this probe; anchoring on the package makes both + # robust against future module moves. + import nexent + module_dir = os.path.dirname(os.path.abspath(nexent.__file__)) test_image_path = os.path.join(module_dir, "assets", "git-flow.png") if os.path.exists(test_image_path): base64_image = self.encode_image(test_image_path) diff --git a/sdk/nexent/core/models/retry.py b/sdk/nexent/core/models/retry.py index 17491db549..b48b86ee2d 100644 --- a/sdk/nexent/core/models/retry.py +++ b/sdk/nexent/core/models/retry.py @@ -13,8 +13,9 @@ network / connection / timeout failures). Authentication errors, not-found, invalid payloads and context-length errors are treated as non-retryable so we fail fast instead of burning backoff on hopeless requests. -* Empty responses (stream completed without user-visible content) are handled - by the caller (the agent step loop / summary truncation), **not** here. +* Empty ``stop`` responses share the same physical-call budget as provider + failures. The model adapter detects them and raises the typed terminal error + defined here after the budget is exhausted. """ from __future__ import annotations @@ -22,6 +23,8 @@ import random from dataclasses import dataclass +from ..model_errors import ModelErrorCode, ModelInvocationTerminalError + @dataclass class ModelRetryConfig: @@ -39,7 +42,7 @@ class ModelRetryConfig: across many concurrent clients. """ - max_attempts: int = 6 + max_attempts: int = 5 backoff_base_seconds: float = 2.0 max_backoff_seconds: float = 30.0 jitter: bool = True @@ -63,7 +66,13 @@ def calculate_backoff(self, attempt: int) -> float: DEFAULT_MODEL_RETRY = ModelRetryConfig() -def classify_model_error(exc: BaseException) -> str: +@dataclass(frozen=True) +class ModelErrorClassification: + retryable: bool + error_code: ModelErrorCode + + +def classify_model_error(exc: BaseException) -> ModelErrorClassification: """Classify an exception raised by a model invocation. Returns ``"retryable"`` for transient errors (rate limiting, server-side @@ -78,9 +87,17 @@ def classify_model_error(exc: BaseException) -> str: """ status = getattr(exc, "status_code", None) if isinstance(status, int): - if status == 429 or 500 <= status < 600: - return "retryable" - return "non_retryable" + if status == 429: + return ModelErrorClassification(True, ModelErrorCode.RATE_LIMIT_EXHAUSTED) + if 500 <= status < 600: + return ModelErrorClassification(True, ModelErrorCode.SERVICE_UNAVAILABLE) + if status in (401, 403): + return ModelErrorClassification(False, ModelErrorCode.AUTHENTICATION_ERROR) + if status == 404: + return ModelErrorClassification(False, ModelErrorCode.NOT_FOUND) + if status in (400, 422): + return ModelErrorClassification(False, ModelErrorCode.INVALID_REQUEST) + return ModelErrorClassification(False, ModelErrorCode.UNKNOWN_ERROR) msg = str(exc).lower() non_retryable_markers = ( @@ -89,9 +106,19 @@ def classify_model_error(exc: BaseException) -> str: "invalid", "api key", "authentication", "context_length", "context length", "token limit", ) - for marker in non_retryable_markers: - if marker in msg: - return "non_retryable" + if any(marker in msg for marker in ("context_length", "context length", "token limit")): + return ModelErrorClassification(False, ModelErrorCode.CONTEXT_OVERFLOW) + if any(marker in msg for marker in ("401", "unauthorized", "403", "forbidden", "api key", "authentication")): + return ModelErrorClassification(False, ModelErrorCode.AUTHENTICATION_ERROR) + if any(marker in msg for marker in ("404", "not found")): + return ModelErrorClassification(False, ModelErrorCode.NOT_FOUND) + if any(marker in msg for marker in non_retryable_markers): + return ModelErrorClassification(False, ModelErrorCode.INVALID_REQUEST) + + if isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower(): + return ModelErrorClassification(True, ModelErrorCode.TIMEOUT) + if isinstance(exc, ConnectionError): + return ModelErrorClassification(True, ModelErrorCode.CONNECTION_ERROR) retryable_markers = ( "429", "rate limit", "rate_limit", @@ -105,10 +132,16 @@ def classify_model_error(exc: BaseException) -> str: ) for marker in retryable_markers: if marker in msg: - return "retryable" + if "timeout" in marker or "timed out" in marker or "time out" in marker or marker == "etimedout": + return ModelErrorClassification(True, ModelErrorCode.TIMEOUT) + if marker in {"429", "rate limit", "rate_limit"}: + return ModelErrorClassification(True, ModelErrorCode.RATE_LIMIT_EXHAUSTED) + if marker in {"500", "502", "503", "504", "server error", "service unavailable", "temporarily unavailable", "try again", "gateway timeout", "bad gateway"}: + return ModelErrorClassification(True, ModelErrorCode.SERVICE_UNAVAILABLE) + return ModelErrorClassification(True, ModelErrorCode.CONNECTION_ERROR) # Unknown error: prefer failing fast over retrying blindly. - return "non_retryable" + return ModelErrorClassification(False, ModelErrorCode.UNKNOWN_ERROR) def get_retry_after_seconds(exc: BaseException) -> float | None: diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py index 08e7962d78..0a39bde286 100644 --- a/sdk/nexent/core/utils/observer.py +++ b/sdk/nexent/core/utils/observer.py @@ -40,6 +40,7 @@ class ProcessType(Enum): MODEL_OUTPUT_THINKING = "model_output_thinking" # model streaming output, thinking content MODEL_OUTPUT_DEEP_THINKING = "model_output_deep_thinking" # model streaming output, deep thinking content MODEL_OUTPUT_CODE = "model_output_code" # model streaming output, code content + MODEL_ATTEMPT_CONTROL = "model_attempt_control" # hidden begin/rollback/commit boundary STEP_COUNT = "step_count" # current step of agent PARSE = "parse" # code parsing result @@ -177,6 +178,9 @@ def __init__(self, lang="zh", enable_nl2a_wrapper=False): self._current_invocation_id: ContextVar[str | None] = ContextVar( "current_invocation_id", default=None ) + self._model_attempt_id: ContextVar[str | None] = ContextVar( + "model_attempt_id", default=None + ) @property def token_buffer(self) -> deque: @@ -248,6 +252,7 @@ def _init_message_transformers(self): ProcessType.PLAN: default_transformer, ProcessType.PLAN_STEP_UPDATE: default_transformer, ProcessType.AUTOMATION_PROPOSAL: default_transformer, + ProcessType.MODEL_ATTEMPT_CONTROL: default_transformer, } def _active_subagent(self) -> tuple | None: @@ -274,6 +279,7 @@ def _emit( invocation_id: str | None = None, explicit_agent_id: bool = False, explicit_invocation_id: bool = False, + metadata: dict[str, Any] | None = None, ) -> None: """Append a ``Message`` with the current sub-agent context auto-stamped. @@ -305,9 +311,51 @@ def _emit( depth=resolved_depth, tool_call_id=tool_call_id, invocation_id=resolved_invocation, + attempt_id=( + self._model_attempt_id.get() + if process_type in { + ProcessType.MODEL_OUTPUT_THINKING, + ProcessType.MODEL_OUTPUT_DEEP_THINKING, + ProcessType.MODEL_OUTPUT_CODE, + } + else None + ), + metadata=metadata, ).to_json() ) + def _reset_model_stream_state(self) -> None: + self.token_buffer.clear() + self.think_buffer.clear() + self.current_mode = ProcessType.MODEL_OUTPUT_THINKING + self.in_think_mode = False + + def begin_model_attempt(self, attempt_id: str, attempt: int) -> None: + self._reset_model_stream_state() + self._model_attempt_id.set(attempt_id) + self._emit( + ProcessType.MODEL_ATTEMPT_CONTROL, + "", + metadata={"phase": "begin", "attempt_id": attempt_id, "attempt": attempt}, + ) + + def rollback_model_attempt(self, attempt_id: str, attempt: int) -> None: + self._reset_model_stream_state() + self._emit( + ProcessType.MODEL_ATTEMPT_CONTROL, + "", + metadata={"phase": "rollback", "attempt_id": attempt_id, "attempt": attempt}, + ) + self._model_attempt_id.set(None) + + def commit_model_attempt(self, attempt_id: str, attempt: int) -> None: + self._emit( + ProcessType.MODEL_ATTEMPT_CONTROL, + "", + metadata={"phase": "commit", "attempt_id": attempt_id, "attempt": attempt}, + ) + self._model_attempt_id.set(None) + def add_model_new_token(self, new_token): """ Process streaming tokens with real-time think tag detection and content classification @@ -607,6 +655,11 @@ def add_message(self, agent_name, process_type, content, **kwargs): agent_id=kwargs.get("agent_id"), agent_name=kwargs.get("agent_name"), explicit_agent_id=explicit_agent_id, + metadata={ + key: kwargs[key] + for key in ("error_code", "retryable") + if key in kwargs + }, ) @contextmanager @@ -750,7 +803,8 @@ class Message: def __init__(self, message_type: ProcessType, content, tool_name: str = None, tool_arguments: dict = None, agent_id=None, agent_name: str = None, depth: int = 0, tool_call_id: str | None = None, - invocation_id: str | None = None): + invocation_id: str | None = None, attempt_id: str | None = None, + metadata: dict[str, Any] | None = None): self.message_type = message_type self.content = content self.tool_name = tool_name @@ -760,6 +814,8 @@ def __init__(self, message_type: ProcessType, content, tool_name: str = None, self.depth = depth self.tool_call_id = tool_call_id self.invocation_id = invocation_id + self.attempt_id = attempt_id + self.metadata = metadata or {} # generate json format and convert to string def to_json(self): @@ -786,4 +842,7 @@ def to_json(self): result["depth"] = self.depth if self.invocation_id is not None: result["invocation_id"] = self.invocation_id + if self.attempt_id is not None: + result["attempt_id"] = self.attempt_id + result.update(self.metadata) return json.dumps(result, ensure_ascii=False) diff --git a/test/backend/app/test_agent_evaluation_app.py b/test/backend/app/test_agent_evaluation_app.py index b144df716b..c01cfbb113 100644 --- a/test/backend/app/test_agent_evaluation_app.py +++ b/test/backend/app/test_agent_evaluation_app.py @@ -92,6 +92,7 @@ def _register_package(name: str) -> types.ModuleType: "services", "services.agent_evaluation_service", "services.evaluation_report_service", + "services.runtime_proxy_service", "database", "database.agent_evaluation_db", "utils", @@ -100,6 +101,7 @@ def _register_package(name: str) -> types.ModuleType: _register_package(_name) sys.modules["services.agent_evaluation_service"] = MagicMock(name="agent_eval_svc") sys.modules["services.evaluation_report_service"] = MagicMock(name="report_svc") +sys.modules["services.runtime_proxy_service"] = MagicMock(name="runtime_proxy_svc") sys.modules["database.agent_evaluation_db"] = MagicMock(name="agent_eval_db") sys.modules["utils.auth_utils"] = MagicMock(name="auth_utils") @@ -179,7 +181,7 @@ def _mock_impls(**overrides): return_value={"items": [], "total": 0} ), "list_agent_evaluations_by_agent_impl": MagicMock(return_value=[{"id": 1}]), - "trial_run_evaluator_impl": AsyncMock(return_value={"result": "ok"}), + "forward_agent_evaluation_trial_run": AsyncMock(return_value={"result": "ok"}), "generate_agent_evaluation_report_impl": MagicMock( return_value=(b"%PDF-1.4 fake", 0) ), @@ -593,7 +595,7 @@ def test_runs_trial(self, client): ) assert response.status_code == 200 assert response.json()["data"] == {"result": "ok"} - assert app.trial_run_evaluator_impl.call_args.kwargs["query"] == "hello" + assert app.forward_agent_evaluation_trial_run.call_args.kwargs["query"] == "hello" def test_401_on_unauthorized(self, client): from consts.exceptions import UnauthorizedError @@ -607,7 +609,7 @@ def test_401_on_unauthorized(self, client): def test_500_on_exception(self, client): _mock_impls( - trial_run_evaluator_impl=AsyncMock(side_effect=RuntimeError("boom")) + forward_agent_evaluation_trial_run=AsyncMock(side_effect=RuntimeError("boom")) ) response = client.post( "/agent-evaluations/trial-run", @@ -617,7 +619,7 @@ def test_500_on_exception(self, client): def test_app_exception_propagates(self, client): _mock_impls( - trial_run_evaluator_impl=AsyncMock( + forward_agent_evaluation_trial_run=AsyncMock( side_effect=_exc(_code("COMMON_RESOURCE_NOT_FOUND"), "missing") ) ) diff --git a/test/backend/app/test_agent_evaluation_runtime_app.py b/test/backend/app/test_agent_evaluation_runtime_app.py index e13c7f94b7..3842438a52 100644 --- a/test/backend/app/test_agent_evaluation_runtime_app.py +++ b/test/backend/app/test_agent_evaluation_runtime_app.py @@ -1,6 +1,6 @@ """Tests for runtime-owned evaluation dispatch.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException @@ -8,12 +8,64 @@ import apps.agent_evaluation_runtime_app as runtime_app from apps.agent_evaluation_runtime_app import ( EvaluationRunRequest, + TrialRunRequest, dispatch_evaluation_run_api, + trial_run_evaluation_api, ) from consts.evaluation_status import EvalRunStatus from consts.exceptions import AppException +@pytest.mark.asyncio +async def test_trial_run_uses_internal_identity_and_runtime_executor(monkeypatch): + monkeypatch.setattr(runtime_app, "verify_internal_runtime_jwt", lambda _: ("u1", "t1")) + executor = AsyncMock(return_value={"answer": "ok", "scores": {"judge": 1.0}}) + monkeypatch.setattr(runtime_app, "_load_trial_executor", lambda: executor) + + result = await trial_run_evaluation_api( + TrialRunRequest( + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=[5], + ), + "internal-token", + ) + + assert result == {"answer": "ok", "scores": {"judge": 1.0}} + executor.assert_awaited_once_with( + tenant_id="t1", + user_id="u1", + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=[5], + language="zh", + ) + + +@pytest.mark.asyncio +async def test_trial_run_rejects_missing_internal_token_without_executing(monkeypatch): + monkeypatch.setattr( + runtime_app, + "verify_internal_runtime_jwt", + MagicMock(side_effect=ValueError("invalid token")), + ) + load_executor = MagicMock() + monkeypatch.setattr(runtime_app, "_load_trial_executor", load_executor) + + with pytest.raises(HTTPException) as exc_info: + await trial_run_evaluation_api( + TrialRunRequest(agent_id=7, query="hello", judge_model_id=99), + None, + ) + + assert exc_info.value.status_code == 401 + load_executor.assert_not_called() + + @pytest.mark.asyncio async def test_dispatch_claims_pending_run_and_submits_runtime_worker(monkeypatch): monkeypatch.setattr(runtime_app, "verify_internal_runtime_jwt", lambda _: ("u1", "t1")) diff --git a/test/backend/app/test_evaluation_delete_app.py b/test/backend/app/test_evaluation_delete_app.py index a554ecf772..1a53350e05 100644 --- a/test/backend/app/test_evaluation_delete_app.py +++ b/test/backend/app/test_evaluation_delete_app.py @@ -41,7 +41,7 @@ "update_evaluation_set_case_impl", ), "database.agent_evaluation_db": ("update_annotation_schema_ids",), - "utils.auth_utils": ("get_current_user_id", "get_current_user_info"), + "utils.auth_utils": ("get_current_user_id", "get_current_user_info", "generate_internal_runtime_jwt"), "utils.evaluation_set_excel_utils": ( "build_evaluation_set_excel_template_bytes", "parse_evaluation_cases_from_excel", ), diff --git a/test/backend/app/test_human_interaction_app.py b/test/backend/app/test_human_interaction_app.py index 3844075c48..671f66e75e 100644 --- a/test/backend/app/test_human_interaction_app.py +++ b/test/backend/app/test_human_interaction_app.py @@ -19,7 +19,7 @@ @pytest.fixture def boundary(monkeypatch): service = MagicMock() - service.snapshot.return_value = {"run_id": RUN, "status": "WAITING_HUMAN"} + service.light_snapshot.return_value = {"run_id": RUN, "status": "WAITING_HUMAN"} service.repository.latest.return_value = RUN service.decide.return_value = {"accepted": True} service.control.return_value = {"run_id": RUN} @@ -39,7 +39,16 @@ def test_snapshot_and_conversation_use_signed_tenant_and_user(boundary): assert response.status_code == 200 verify.assert_called_once_with("Bearer internal-token") service.repository.latest.assert_called_once_with("tenant-a", "owner", 7) - service.snapshot.assert_called_once_with(RUN, "tenant-a", "owner") + service.light_snapshot.assert_called_once_with(RUN, "tenant-a", "owner") + + +def test_run_snapshot_uses_signed_tenant_and_user(boundary): + client, service, verify = boundary + response = client.get(BASE + f"/{RUN}", headers={"Authorization": "Bearer internal-token"}) + assert response.status_code == 200 + assert response.json() == {"run_id": RUN, "status": "WAITING_HUMAN"} + verify.assert_called_once_with("Bearer internal-token") + service.light_snapshot.assert_called_once_with(RUN, "tenant-a", "owner") @pytest.mark.parametrize("path", ["/capabilities", f"/{RUN}", "/conversation/7", f"/{RUN}/events"]) @@ -47,7 +56,7 @@ def test_invalid_internal_token_is_rejected(boundary, path): client, service, verify = boundary verify.side_effect = UnauthorizedError("Invalid internal runtime token") assert client.get(BASE + path).status_code == 401 - service.snapshot.assert_not_called() + service.light_snapshot.assert_not_called() @pytest.mark.parametrize("status", [404, 409, 410, 422, 503]) diff --git a/test/backend/services/test_agent_evaluation_service.py b/test/backend/services/test_agent_evaluation_service.py index 2f94f6ae14..795fd6a274 100644 --- a/test/backend/services/test_agent_evaluation_service.py +++ b/test/backend/services/test_agent_evaluation_service.py @@ -448,6 +448,7 @@ def __init__(self, error_code=None, message=None, *args, **kwargs): "services.thread_lifecycle_service" ) _thread_lifecycle_service_module.runtime_thread_manager = MagicMock() +_thread_lifecycle_service_module.config_thread_manager = MagicMock() sys.modules["services.thread_lifecycle_service"] = _thread_lifecycle_service_module _services_pkg.thread_lifecycle_service = _thread_lifecycle_service_module @@ -1150,7 +1151,7 @@ def test_create_agent_evaluation_run_happy_path(service_module): pool_mock = MagicMock() future = MagicMock() pool_mock.submit.return_value = types.SimpleNamespace(future=future) - service_module.runtime_thread_manager = pool_mock + service_module.config_thread_manager = pool_mock run = service_module.create_agent_evaluation_run_impl( tenant_id="t1", @@ -1179,6 +1180,7 @@ def test_create_agent_evaluation_run_happy_path(service_module): assert len(kwargs["set_cases"]) == 3 pool_mock.submit.assert_called_once() + service_module.runtime_thread_manager.submit.assert_not_called() future.add_done_callback.assert_called_once() # Done-callback signature should be a callable wrapping the run id + tenant. callback = future.add_done_callback.call_args.args[0] @@ -1207,8 +1209,8 @@ def test_create_agent_evaluation_run_uses_resolved_version_no(service_module): """The published version number flows from ``resolve_latest_published_version_no``.""" create_mock = _wire_full_db_module(service_module) service_module.resolve_latest_published_version_no.return_value = 13 - service_module.runtime_thread_manager = MagicMock() - service_module.runtime_thread_manager.submit.return_value = types.SimpleNamespace( + service_module.config_thread_manager = MagicMock() + service_module.config_thread_manager.submit.return_value = types.SimpleNamespace( future=MagicMock() ) diff --git a/test/backend/services/test_agent_model_attempt_persistence.py b/test/backend/services/test_agent_model_attempt_persistence.py new file mode 100644 index 0000000000..422d60ae38 --- /dev/null +++ b/test/backend/services/test_agent_model_attempt_persistence.py @@ -0,0 +1,172 @@ +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from management.services.agent import run as agent_run_service +from management.services.agent.run import ( + _finalize_buffered_unit_fragments, + _is_continuation, + _rollback_model_attempt_units, +) + + +def test_cmsr_003_rollback_removes_only_matching_attempt_units(): + units = [ + {"type": "model_output_thinking", "_attempt_id": "failed", "unit_content": "leak"}, + {"type": "model_output_thinking", "_attempt_id": "sibling", "unit_content": "keep"}, + {"type": "step_count", "unit_content": "step"}, + ] + + assert _rollback_model_attempt_units(units, "failed") == 1 + assert [unit["unit_content"] for unit in units] == ["keep", "step"] + + +def test_cmsr_003_attempt_metadata_is_never_persisted(): + units = [ + { + "type": "model_output_thinking", + "_attempt_id": "successful", + "_content_fragments": ["hello", " world"], + "unit_content": "", + } + ] + + assert _finalize_buffered_unit_fragments(units) == len("hello world") + assert units == [ + { + "type": "model_output_thinking", + "unit_content": "hello world", + "content": "hello world", + } + ] + + +def test_cmsr_003_continuation_requires_matching_attempt_and_invocation(): + current = { + "type": "model_output_thinking", + "_attempt_id": "attempt-1", + "invocation_id": "subagent-1", + } + matching = {"attempt_id": "attempt-1", "invocation_id": "subagent-1"} + sibling = {"attempt_id": "attempt-2", "invocation_id": "subagent-1"} + + assert _is_continuation(current, True, "model_output_thinking", matching) + assert not _is_continuation(current, True, "model_output_thinking", sibling) + + +def _agent_request(): + return SimpleNamespace( + conversation_id=999, + history=[], + is_debug=False, + ) + + +def _agent_run_info(outcome: str = "completed"): + return SimpleNamespace( + agent_config=SimpleNamespace(pre_run_tool_events=()), + cancellation_scope=None, + stop_event=asyncio.Event(), + human_interaction=None, + attempt_outcome=outcome, + thread_future=None, + ) + + +def _configure_stream_mocks(monkeypatch, persisted_batches): + monkeypatch.setattr(agent_run_service, "save_message", MagicMock(return_value=4242)) + monkeypatch.setattr( + agent_run_service, + "persist_assistant_run_batch", + lambda **kwargs: persisted_batches.append(kwargs), + ) + monkeypatch.setattr( + agent_run_service, "_unregister_agent_run_after_execution", MagicMock() + ) + monkeypatch.setattr( + agent_run_service.streaming_channel_manager, "complete_channel", AsyncMock() + ) + monkeypatch.setattr(agent_run_service, "_cleanup_channel_later", AsyncMock()) + + async def run_managed(_lane, _spec, fn, *args, **kwargs): + return fn(*args, **kwargs) + + async def wait_for_cancel(*_args, **_kwargs): + await asyncio.Event().wait() + + monkeypatch.setattr(agent_run_service.runtime_thread_manager, "run", run_managed) + monkeypatch.setattr(agent_run_service, "_poll_runtime_cancel_signal", wait_for_cancel) + + +@pytest.mark.asyncio +async def test_cmsr_003_stream_rolls_back_failed_attempt_before_persistence(monkeypatch): + async def fake_agent_run(*_args, **_kwargs): + yield json.dumps({"type": "model_attempt_control", "content": "", "phase": "begin", "attempt_id": "failed"}) + yield json.dumps({"type": "model_output_thinking", "content": "leak", "attempt_id": "failed"}) + yield json.dumps({"type": "model_attempt_control", "content": "", "phase": "rollback", "attempt_id": "failed"}) + yield json.dumps({"type": "model_attempt_control", "content": "", "phase": "begin", "attempt_id": "success"}) + yield json.dumps({"type": "model_output_thinking", "content": "keep", "attempt_id": "success"}) + yield json.dumps({"type": "model_attempt_control", "content": "", "phase": "commit", "attempt_id": "success"}) + yield json.dumps({"type": "final_answer", "content": "done"}) + + persisted_batches = [] + _configure_stream_mocks(monkeypatch, persisted_batches) + monkeypatch.setattr(agent_run_service, "agent_run", fake_agent_run) + channel = SimpleNamespace(publish=AsyncMock()) + + chunks = [ + chunk + async for chunk in agent_run_service._stream_agent_chunks( + _agent_request(), + "user1", + "tenant1", + _agent_run_info(), + MagicMock(), + channel=channel, + ) + ] + + assert len(chunks) == 7 + assert len(persisted_batches) == 1 + units = persisted_batches[0]["message_units"] + assert [unit["unit_content"] for unit in units] == ["keep", "done"] + assert all("_attempt_id" not in unit for unit in units) + + +@pytest.mark.asyncio +async def test_cmsr_004_terminal_error_is_persisted_once_with_failed_status(monkeypatch): + async def fake_agent_run(*_args, **_kwargs): + yield json.dumps( + { + "type": "error", + "content": "The model request failed.", + "error_code": "model_unknown_error", + "retryable": False, + } + ) + + persisted_batches = [] + _configure_stream_mocks(monkeypatch, persisted_batches) + monkeypatch.setattr(agent_run_service, "agent_run", fake_agent_run) + channel = SimpleNamespace(publish=AsyncMock()) + + chunks = [ + chunk + async for chunk in agent_run_service._stream_agent_chunks( + _agent_request(), + "user1", + "tenant1", + _agent_run_info("failed"), + MagicMock(), + channel=channel, + ) + ] + + error_chunks = [chunk for chunk in chunks if '"type": "error"' in chunk] + assert len(error_chunks) == 1 + assert '"retryable": false' in error_chunks[0] + assert persisted_batches[0]["terminal_status"] == "failed" + assert [unit["type"] for unit in persisted_batches[0]["message_units"]] == ["error"] diff --git a/test/backend/services/test_application_execute_attempt.py b/test/backend/services/test_application_execute_attempt.py new file mode 100644 index 0000000000..cff6064a2e --- /dev/null +++ b/test/backend/services/test_application_execute_attempt.py @@ -0,0 +1,421 @@ +"""Unit test for application.py execute_attempt async consumer loop. + +Covers the _flush_if_due batch/threshold flush (peek-then-take, begin_emit / +end_emit lifecycle) and the final flush in the ``finally`` block. No real +database or agent — every collaborator is mocked. This lets us exercise the +patch lines added in the HITL reorder PR without depending on the full +Postgres-backed test suite. +""" + +import asyncio +import contextlib +import threading +import types +from unittest.mock import MagicMock, patch + +import pytest + + +async def _run_blocking_mock(*args, **kwargs): + """Drop-in replacement for ``run_blocking`` that calls ``fn(*a, **kw)`` + in the current thread and awaits nothing extra. This MUST be async so + ``await run_blocking(...)`` works when patched in. + """ + fn = args[1] + rest = args[2:] + return fn(*rest) + + +async def _authorize_mock(*args, **kwargs): + return None + + +def _build_port_class(on_finish): + """Build a DB-free ``RuntimeInteractionPort`` subclass for consumer-loop tests. + + Storage collaborators are stubbed in-memory: chunk emissions are tracked + on ``self._emits`` and terminal outcomes are forwarded to ``on_finish`` + instead of hitting the database. + """ + from services.human_interaction.runtime_port import RuntimeInteractionPort + + class _Port(RuntimeInteractionPort): + def __init__(self, *a, **kw): + self._chunk_buffer = [] + self._chunk_buffer_lock = threading.Lock() + self._emit_in_flight = threading.Event() + self.service = MagicMock() + self.run_id = "run-1" + self.tenant_id = "tenant-1" + self.user_id = "user-1" + self.owner_id = "worker" + self.fence = "fence-1" + self.request_payload = { + "runtime_mode": "native-live-v1", + "request": {"agent_id": 1, "conversation_id": 7, "query": "hi", "enable_hitl": True}, + "language": "en", + "runtime_metadata": {}, + "runtime_metadata_version": 1, + "runtime_knowledge_context": None, + } + self.checkpoint = None + self.allowed_tools = frozenset() + self.live_resume = True + self.stop_event = None + self._emits: list[list[str]] = [] + + def transaction(self, *a, **kw): + return contextlib.contextmanager(lambda: iter([MagicMock()]))() + + def context_snapshot(self, items): + return [] + + def bind_catalog(self, catalog): + return None + + def emit_chunks(self, chunks): + self._emits.append(list(chunks)) + + def finish(self, outcome): + on_finish(outcome) + + return _Port + + +def _make_port_factory(fake_info, port_ref, on_finish, port_hook=None): + """Build the ``RuntimeInteractionPort`` replacement used by the tests. + + The returned factory instantiates the fake port, applies the optional + ``port_hook`` for per-test tweaking, wires it into ``fake_info`` and + records the instance in ``port_ref`` for later assertions. + """ + port_cls = _build_port_class(on_finish) + + def make_port(*_a, **_kw): + p = port_cls() + if port_hook is not None: + port_hook(p) + port_ref["p"] = p + fake_info.human_interaction = MagicMock() + fake_info.human_interaction.port = p + return p + + return make_port + + +def _execute_attempt_args(): + """Standard job/payload and owner handed to ``execute_attempt``.""" + return ( + types.SimpleNamespace( + job_id="j", + payload={ + "run_id": "run-1", + "tenant_id": "tenant-1", + "user_id": "user-1", + "conversation_id": 7, + }, + ), + types.SimpleNamespace(owner_id="worker"), + ) + + +@contextlib.contextmanager +def _patched_application(make_port, fake_stream, prepare_mock): + """Patch ``execute_attempt``'s full dependency chain; yields the module.""" + from services.human_interaction import application + + with patch.object(application, "get_service", lambda: MagicMock()), \ + patch.object(application, "RuntimeInteractionPort", make_port), \ + patch.object(application, "authorize_run", _authorize_mock), \ + patch("nexent.core.concurrency.run_blocking", _run_blocking_mock), \ + patch("management.services.agent.run.prepare_agent_run", prepare_mock), \ + patch("management.services.agent.run._stream_agent_chunks", fake_stream), \ + patch("management.services.agent.run._unregister_agent_run_after_execution", + lambda *_a, **_kw: None), \ + patch("agents.agent_run_manager.agent_run_manager.unregister_agent_run", + lambda *_: None): + yield application + + +async def test_flush_if_due_uses_peek_then_take_without_transiently_empty_buffer(): + """_flush_if_due never does take-put-back — it peeks first, then only + drains when actually ready to persist. Eliminates the race where the + worker's idle poll sees an empty buffer between take and put-back. + """ + calls: list[tuple] = [] + + async def fake_stream(**_): + for t in range(3): + yield f"chunk-{t}" + # A small sleep lets the timeout path fire once. + await asyncio.sleep(0.07) + yield "final" + + class FakeInfo: + human_interaction = None + agent_config = MagicMock() + agent_config.tools = [] + context_input = MagicMock(items=()) + model_config_list = [] + runtime_metadata = {} + attempt_outcome = None + + fake_info = FakeInfo() + port_ref: dict = {} + make_port = _make_port_factory( + fake_info, port_ref, lambda outcome: calls.append(("finish", outcome))) + + async def prepare_mock(**kwargs): + return fake_info, None + + with _patched_application(make_port, fake_stream, prepare_mock) as application: + try: + await application.execute_attempt(*_execute_attempt_args()) + except StopAsyncIteration: + # The async consumer loop hit the StopAsyncIteration re-raised + # from the finally block — that's fine, we still ran through + # the entire consumer loop including _flush_if_due + final flush. + pass + except Exception as exc: + pytest.fail(f"execute_attempt raised unexpected {type(exc).__name__}: {exc!r}") + + port = port_ref["p"] + total_persisted = sum(len(batch) for batch in port._emits) + # Timing-sensitive — the sleep in fake_stream may cause the "final" chunk + # to land in either the timed _flush_if_due path or the final-flush path. + # Either way, every chunk that was added must be accounted for. + assert total_persisted >= 3, ( + f"Expected >=3 chunks persisted, got {total_persisted} batches={port._emits}" + ) + + # begin_emit must have been paired with end_emit — otherwise we would + # have seen _emit_in_flight still set after the loop. + assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + + +# --- exception / finally path coverage --------------------------------------- + +class _FakeInfo: + human_interaction = None + agent_config = MagicMock() + agent_config.tools = [] + context_input = MagicMock(items=()) + model_config_list = [] + runtime_metadata = {} + attempt_outcome = None + cancellation_scope = None + stop_event = None + + +async def _run_execute_attempt(fake_stream, fake_info, *, port_hook=None, refs=None): + """Run execute_attempt with the full dependency chain patched. + + Returns ``(port, finish_calls)`` on success. ``refs`` (optional dict) is + populated BEFORE execution so callers can inspect state even when + execute_attempt raises. + """ + finish_calls: list = [] + port_ref: dict = {} + make_port = _make_port_factory(fake_info, port_ref, finish_calls.append, port_hook=port_hook) + + async def prepare_mock(**kwargs): + return fake_info, None + + if refs is not None: + refs["port_ref"] = port_ref + refs["finish_calls"] = finish_calls + + with _patched_application(make_port, fake_stream, prepare_mock) as application: + await application.execute_attempt(*_execute_attempt_args()) + + return port_ref["p"], finish_calls + + +async def test_leftover_chunks_flush_in_finally_before_failed_finish(): + """Chunks buffered when the loop errors mid-stream must be persisted by the + finally-block leftover flush BEFORE finish("failed") writes the status row. + """ + from services.human_interaction import application + + info = _FakeInfo() + + async def fake_stream(**_): + yield "chunk-0" + yield "chunk-1" + yield "chunk-2" + + def fail_third_add(port): + original_add = port.add_chunk + seen = {"n": 0} + + def add_chunk(chunk): + seen["n"] += 1 + if seen["n"] == 3: + raise RuntimeError("mid-loop failure") + original_add(chunk) + + port.add_chunk = add_chunk + + refs: dict = {} + with pytest.raises(RuntimeError, match="mid-loop failure"): + await _run_execute_attempt(fake_stream, info, port_hook=fail_third_add, refs=refs) + + port = refs["port_ref"]["p"] + # The two buffered chunks were flushed by the finally block, before finish. + assert port._emits == [["chunk-0", "chunk-1"]], port._emits + assert refs["finish_calls"] == ["failed"] + assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + + +async def test_cancelled_error_cancels_scope_and_reraises_without_finish(): + """Cancellation propagates: the run scope is cancelled and no terminal + status row is written — the run stays resumable. + """ + from services.human_interaction import application + + info = _FakeInfo() + info.cancellation_scope = MagicMock() + + async def fake_stream(**_): + raise asyncio.CancelledError() + yield # pragma: no cover + + refs: dict = {} + with pytest.raises(asyncio.CancelledError): + await _run_execute_attempt(fake_stream, info, refs=refs) + + info.cancellation_scope.cancel.assert_called_once() + assert refs["finish_calls"] == [] + + +async def test_cancelled_error_falls_back_to_stop_event_when_scope_missing(): + """Without a cancellation scope, the stop event is the cancellation signal.""" + import threading + + info = _FakeInfo() + info.cancellation_scope = None + info.stop_event = threading.Event() + + async def fake_stream(**_): + raise asyncio.CancelledError() + yield # pragma: no cover + + refs: dict = {} + with pytest.raises(asyncio.CancelledError): + await _run_execute_attempt(fake_stream, info, refs=refs) + + assert info.stop_event.is_set() + assert refs["finish_calls"] == [] + + +async def test_run_terminated_finishes_stopped_and_swallows_finish_race(): + """RunTerminated writes the "stopped" terminal row; a RunTerminated raised + by the terminal write itself (status raced the stop) is swallowed so no + exception escapes execute_attempt. + """ + from services.human_interaction import application + + info = _FakeInfo() + run_terminated = application.RunTerminated + + async def fake_stream(**_): + raise run_terminated("terminated") + yield # pragma: no cover + + refs: dict = {} + + def racy_finish(port): + original_finish = port.finish + + def finish(outcome): + original_finish(outcome) # records into refs["finish_calls"] + raise run_terminated("terminal write raced the stop") + + port.finish = finish + + port, finish_calls = await _run_execute_attempt( + fake_stream, info, port_hook=racy_finish, refs=refs) + assert refs["finish_calls"] == ["stopped"] + assert finish_calls == ["stopped"] + + +async def test_run_terminated_finishes_stopped(): + """RunTerminated → finish("stopped") and no exception escapes.""" + from services.human_interaction import application + + info = _FakeInfo() + + async def fake_stream(**_): + raise application.RunTerminated("terminated") + yield # pragma: no cover + + refs: dict = {} + port, finish_calls = await _run_execute_attempt(fake_stream, info, refs=refs) + assert finish_calls == ["stopped"] + + +async def test_recovery_required_finishes_with_recovery_outcome(): + """RecoveryRequired → finish("recovery_required") so the run can be retried.""" + from services.human_interaction import application + + info = _FakeInfo() + + async def fake_stream(**_): + raise application.RecoveryRequired("lease lost") + yield # pragma: no cover + + port, finish_calls = await _run_execute_attempt(fake_stream, info) + assert finish_calls == ["recovery_required"] + + +async def test_finally_survives_chunk_iterator_aclose_failure(): + """A raising chunk_iter.aclose() in the finally block is swallowed and + must not prevent the leftover flush or the failed-finish from running. + """ + from services.human_interaction import application + + info = _FakeInfo() + + class _AcloseRaises: + """Async iterator that yields one chunk then fails on aclose().""" + + def __init__(self): + self._inner = self._gen() + + async def _gen(self): + yield "chunk-0" + yield "chunk-1" + + def __aiter__(self): + return self + + async def __anext__(self): + return await self._inner.__anext__() + + async def aclose(self): + raise RuntimeError("aclose failed") + + def fail_second_add(port): + original_add = port.add_chunk + seen = {"n": 0} + + def add_chunk(chunk): + seen["n"] += 1 + if seen["n"] == 2: + raise RuntimeError("mid-loop failure") + original_add(chunk) + + port.add_chunk = add_chunk + + def fake_stream(**_): + return _AcloseRaises() + + refs: dict = {} + with pytest.raises(RuntimeError, match="mid-loop failure"): + await _run_execute_attempt(fake_stream, info, port_hook=fail_second_add, refs=refs) + + port = refs["port_ref"]["p"] + # The buffered chunk was still flushed by the finally block despite the + # aclose() failure right before it. + assert port._emits == [["chunk-0"]] + assert refs["finish_calls"] == ["failed"] diff --git a/test/backend/services/test_evaluation_pure_logic.py b/test/backend/services/test_evaluation_pure_logic.py index 58a3d12f4c..a75f7d2e6a 100644 --- a/test/backend/services/test_evaluation_pure_logic.py +++ b/test/backend/services/test_evaluation_pure_logic.py @@ -371,6 +371,7 @@ def __init__(self, code: Any, msg: str = ""): _services_pkg.evaluation_set_service = _ess_mod _tls_mod = _mk_mod( "services.thread_lifecycle_service", + config_thread_manager=MagicMock(), runtime_thread_manager=MagicMock(), ) _services_pkg.thread_lifecycle_service = _tls_mod diff --git a/test/backend/services/test_human_interaction_snapshot.py b/test/backend/services/test_human_interaction_snapshot.py new file mode 100644 index 0000000000..d3f36ea9ab --- /dev/null +++ b/test/backend/services/test_human_interaction_snapshot.py @@ -0,0 +1,150 @@ +"""Unit tests for the read-path snapshot split (snapshot / light_snapshot) +and the repository read_only context manager. + +The Postgres-backed suite in test_human_interaction.py skips without a real +database, so these mocks keep the read-path lines covered in CI. No real +database is required. +""" + +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from database.human_interaction_db import HumanInteractionRepository, RunTransaction +from services.human_interaction.models import InteractionError +from services.human_interaction.service import HumanInteractionService + + +def _future_lock(): + return datetime.now(timezone.utc) + timedelta(hours=1) + + +def _make_run(**overrides): + run = SimpleNamespace( + run_id="run-1", + conversation_id=7, + status="WAITING_HUMAN", + event_seq=4, + pause_requested=True, + lock_until=_future_lock(), + ) + for key, value in overrides.items(): + setattr(run, key, value) + return run + + +def _yielding_ctx(value): + """Fresh single-use context manager yielding ``value`` (exception-safe).""" + + @contextmanager + def ctx(): + yield value + + return ctx() + + +def _make_service(tx): + repo = MagicMock() + repo.read_only.side_effect = lambda *a, **k: _yielding_ctx(tx) + repo.transaction.side_effect = lambda *a, **k: _yielding_ctx(tx) + service = HumanInteractionService(repository=repo, cipher=MagicMock(), wait_seconds=60) + return service, repo + + +def _snapshot_tx(run): + tx = MagicMock() + tx.run = run + tx.requests.return_value = [] + return tx + + +# --- service.light_snapshot --------------------------------------------------- + +def test_light_snapshot_builds_snapshot_without_writer_path(): + run = _make_run() + tx = _snapshot_tx(run) + service, repo = _make_service(tx) + + snap = service.light_snapshot("run-1", "tenant-1", "user-1") + + assert snap["run_id"] == "run-1" + assert snap["conversation_id"] == 7 + assert snap["status"] == "WAITING_HUMAN" + assert snap["event_seq"] == 4 + assert snap["pause_requested"] is True + assert snap["attempt_active"] is True + # Read path only: no write transaction, no expiration side effects. + repo.read_only.assert_called_once_with("run-1", "tenant-1", "user-1") + repo.transaction.assert_not_called() + + +def test_light_snapshot_missing_run_raises_404(): + service, repo = _make_service(None) + + with pytest.raises(InteractionError, match="not found"): + service.light_snapshot("run-1", "tenant-1", "user-1") + + +# --- service.snapshot (writer path) ------------------------------------------- + +def test_snapshot_takes_write_transaction_and_expires(): + run = _make_run() + tx = _snapshot_tx(run) + service, repo = _make_service(tx) + + with patch.object(service, "_expire") as expire: + snap = service.snapshot("run-1", "tenant-1", "user-1") + + assert snap["run_id"] == "run-1" + expire.assert_called_once() + repo.transaction.assert_called_once_with("run-1", "tenant-1", "user-1") + repo.read_only.assert_not_called() + + +# --- repository.read_only ------------------------------------------------------ + +def _repo_with_session(session): + factory = MagicMock(side_effect=lambda: _yielding_ctx(session)) + return HumanInteractionRepository(session_factory=factory), factory + + +def test_read_only_yields_transaction_without_lock_or_writes(): + run = _make_run() + session = MagicMock() + session.scalar.return_value = run + repo, factory = _repo_with_session(session) + + with repo.read_only("run-1", "tenant-1", "user-1") as tx: + assert isinstance(tx, RunTransaction) + assert tx.run is run + assert tx.actor == "user-1" + + # Plain SELECT: no FOR UPDATE lock row, no flush, no commit. + session.flush.assert_not_called() + session.commit.assert_not_called() + factory.assert_called_once_with() + + +def test_read_only_missing_run_yields_none(): + session = MagicMock() + session.scalar.return_value = None + repo, _ = _repo_with_session(session) + + with repo.read_only("run-1") as tx: + assert tx is None + + +def test_read_only_works_with_and_without_tenant_scope(): + run = _make_run() + session = MagicMock() + session.scalar.return_value = run + repo, _ = _repo_with_session(session) + + with repo.read_only("run-1", "tenant-1", "user-1") as scoped: + assert scoped is not None + with repo.read_only("run-1") as unscoped: + assert unscoped is not None + assert session.scalar.call_count == 2 diff --git a/test/backend/services/test_human_interaction_stream.py b/test/backend/services/test_human_interaction_stream.py index 07ae283e9b..b0515e35c2 100644 --- a/test/backend/services/test_human_interaction_stream.py +++ b/test/backend/services/test_human_interaction_stream.py @@ -87,7 +87,7 @@ async def test_durable_event_ids_allow_replay_without_skipping_snapshot_backlog( snapshot = {"run_id": "run", "conversation_id": 7, "status": "COMPLETED", "event_seq": 3} service = MagicMock() - service.snapshot.return_value = snapshot + service.light_snapshot.return_value = snapshot service.repository.events.return_value = [ {"seq": 2, "payload": {"type": "human_decision", "content": {"status": "DECIDED"}}}, {"seq": 3, "payload": {"chunk_cipher": "encrypted"}}, @@ -102,7 +102,7 @@ async def test_durable_event_ids_allow_replay_without_skipping_snapshot_backlog( assert chunks[2] == 'id: 3\ndata: {"type":"final_answer","content":"done"}\n\n' assert chunks[-1].startswith("data: ") service.repository.events.assert_called_once_with("run", 1) - assert all(call.args == ("run", "tenant", "owner") for call in service.snapshot.call_args_list) + assert all(call.args == ("run", "tenant", "owner") for call in service.light_snapshot.call_args_list) @pytest.mark.asyncio @@ -112,7 +112,7 @@ async def test_future_event_cursor_is_rejected_before_streaming(monkeypatch): from services.human_interaction.models import InteractionError service = MagicMock() - service.snapshot.return_value = {"event_seq": 3} + service.light_snapshot.return_value = {"event_seq": 3} monkeypatch.setattr(application, "require_enabled", lambda: service) with pytest.raises(InteractionError) as error: await application.stream_run("run", "tenant", "owner", after=4) diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 29edd3ce6f..45502ffda7 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -211,6 +211,7 @@ class _ProviderEnum: consts_provider_mod.DASHSCOPE_REALTIME_BASE_URL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime" consts_provider_mod.DASHSCOPE_STT_BASE_URL = consts_provider_mod.DASHSCOPE_REALTIME_BASE_URL consts_provider_mod.TOKENPONY_BASE_URL = "https://api.tokenpony.cn/v1/" +consts_provider_mod.MODEL_ENGINE_URL_MARKER = "open/router" sys.modules["consts.provider"] = consts_provider_mod # Stub services.model_provider_service used by service @@ -1784,6 +1785,64 @@ async def test_update_single_model_for_tenant_empty_api_key_sets_ssl_verify_fals assert update_call[0][1]["ssl_verify"] is False +async def test_update_single_model_for_tenant_open_router_url_keeps_ssl_verify_false(): + """Editing a ModelEngine model must not flip ssl_verify to True. + + ModelEngine endpoints serve self-signed certificates, so their records + are created with ssl_verify=False. The edit dialog prefills the real + api_key and always submits it; without the open/router exemption the + update path would silently flip ssl_verify to True and break + connectivity. Mirrors the create-path exemption in + create_model_for_tenant. + """ + svc = import_svc() + + existing_models = [ + { + "model_id": 1, + "model_type": "llm", + "display_name": "name", + "base_url": "https://modelengine.example.com/open/router/v1", + }, + ] + model_data = { + "model_id": 1, + "display_name": "name", + "api_key": "my-secret-key", + } + + with mock.patch.object(svc, "get_models_by_display_name", return_value=existing_models), \ + mock.patch.object(svc, "update_model_record") as mock_update: + + await svc.update_single_model_for_tenant("u1", "t1", "name", model_data) + + update_call = mock_update.call_args + assert update_call[0][1]["ssl_verify"] is False + + +async def test_update_single_model_for_tenant_open_router_url_in_payload_keeps_ssl_verify_false(): + """The open/router exemption also applies when the update payload carries the URL itself.""" + svc = import_svc() + + existing_models = [ + {"model_id": 1, "model_type": "llm", "display_name": "name"}, + ] + model_data = { + "model_id": 1, + "display_name": "name", + "api_key": "my-secret-key", + "base_url": "https://example.com/open/router/v1", + } + + with mock.patch.object(svc, "get_models_by_display_name", return_value=existing_models), \ + mock.patch.object(svc, "update_model_record") as mock_update: + + await svc.update_single_model_for_tenant("u1", "t1", "name", model_data) + + update_call = mock_update.call_args + assert update_call[0][1]["ssl_verify"] is False + + async def test_update_single_model_for_tenant_generic_exception(): """Test that generic exceptions are caught and re-raised (covers lines 329-331).""" svc = import_svc() diff --git a/test/backend/services/test_model_provider_service.py b/test/backend/services/test_model_provider_service.py index 22b6187db4..f1f1fb028e 100644 --- a/test/backend/services/test_model_provider_service.py +++ b/test/backend/services/test_model_provider_service.py @@ -2123,7 +2123,7 @@ async def test_prepare_model_dict_modelengine_base_url_stripping(): @pytest.mark.asyncio async def test_get_provider_models_modelengine_success(): - """ModelEngine provider models are fetched via the unified OpenAI-compatible adapter.""" + """ModelEngine provider models are fetched via the dedicated ModelEngineProvider adapter.""" model_data = {"provider": "modelengine", "model_type": "llm"} expected_models = [ @@ -2136,7 +2136,7 @@ async def test_get_provider_models_modelengine_success(): ] with mock.patch( - "backend.services.model_provider_service.OpenAICompatibleProvider" + "backend.services.model_provider_service.ModelEngineProvider" ) as mock_provider_class: mock_provider_instance = mock.AsyncMock() mock_provider_instance.get_models.return_value = expected_models @@ -2151,11 +2151,11 @@ async def test_get_provider_models_modelengine_success(): @pytest.mark.asyncio async def test_get_provider_models_modelengine_empty_result(): - """Should handle empty result from the unified adapter for ModelEngine provider.""" + """Should handle empty result from the ModelEngine adapter for ModelEngine provider.""" model_data = {"provider": "modelengine", "model_type": "embedding"} with mock.patch( - "backend.services.model_provider_service.OpenAICompatibleProvider" + "backend.services.model_provider_service.ModelEngineProvider" ) as mock_provider_class: mock_provider_instance = mock.AsyncMock() mock_provider_instance.get_models.return_value = [] diff --git a/test/backend/services/test_runtime_port_chunk_buffer.py b/test/backend/services/test_runtime_port_chunk_buffer.py new file mode 100644 index 0000000000..e7eb40f087 --- /dev/null +++ b/test/backend/services/test_runtime_port_chunk_buffer.py @@ -0,0 +1,356 @@ +"""Unit tests for RuntimeInteractionPort shared chunk buffer and idle flush. + +These tests do NOT require a real database — collaborators are mocked. +They cover the thread-safe add_chunk / take_chunks buffer, the +flush_chunks_until_idle poll loop, and verify that every HITL entry point +(dispatch / boundary / receipt / finish / _wait_until_ready) invokes the +idle flush before opening its own transaction. +""" + +import threading +import time +import types +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_port(): + """Build a RuntimeInteractionPort with every DB-dependent collaborator mocked. + + We patch ``transaction`` as a context manager yielding a fake tx, so the + real SQLAlchemy path is never hit. The buffer + idle-flush methods under + test operate entirely on shared state and mock ``emit_chunks``. + """ + from services.human_interaction.runtime_port import RuntimeInteractionPort + + service = MagicMock() + service.repository = MagicMock() + service.cipher = MagicMock() + service.cipher.open.return_value = {} + service.cipher.seal.return_value = "sealed" + + identity = { + "run_id": "run-1", "tenant_id": "tenant-1", "user_id": "user-1", + "fence": "fence-1", + } + + def authorize(): + return None + + with patch.object(RuntimeInteractionPort, "transaction") as mock_tx: + fake_tx = MagicMock() + fake_tx.run.checkpoint = "{}" + fake_tx.run.request_payload = "{}" + ctx = contextmanager(lambda: iter([fake_tx]))() + mock_tx.return_value = ctx + port = RuntimeInteractionPort( + service, identity, "worker", authorize, allowed_tools=("tool_a",), + live_resume=True, + ) + + # Replace the transaction contextmanager with one we control so tests + # that exercise dispatch/boundary/etc. stay DB-free. + port.transaction = MagicMock() + port.transaction.return_value = ctx + port.emit_chunks = MagicMock() + port.authorize = MagicMock() + + return port + + +# --- add_chunk / take_chunks ------------------------------------------------ + +def test_add_chunk_appends_to_buffer(): + port = _make_port() + port.add_chunk("chunk-1") + port.add_chunk("chunk-2") + assert port.take_chunks() == ["chunk-1", "chunk-2"] + + +def test_take_chunks_clears_buffer(): + port = _make_port() + port.add_chunk("x") + assert port.take_chunks() == ["x"] + assert port.take_chunks() == [] + + +def test_add_chunk_take_chunks_thread_safety(): + """Multiple producers must not lose chunks or see torn buffers.""" + port = _make_port() + n_threads = 8 + per_thread = 50 + barrier = threading.Barrier(n_threads) + + def producer(tid): + barrier.wait() + for i in range(per_thread): + port.add_chunk(f"t{tid}-{i}") + + threads = [threading.Thread(target=producer, args=(t,)) for t in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + chunks = port.take_chunks() + assert len(chunks) == n_threads * per_thread + + +# --- flush_chunks_until_idle ------------------------------------------------ + +def test_flush_until_idle_empty_buffer_settles_immediately(): + """If the buffer is already empty, two short polls + settle_ms break.""" + port = _make_port() + # settle_ms=20, so first poll sees empty → idle_since set; second poll + # 20ms later sees empty → break. Total sleep = settle_ms. + port.flush_chunks_until_idle(max_wait_ms=100, settle_ms=20) + port.emit_chunks.assert_not_called() + + +def test_flush_until_idle_persists_then_waits_for_settle(): + """Chunks present → emit_chunks called; then buffer stays empty until settle.""" + port = _make_port() + port.add_chunk("a") + port.add_chunk("b") + + # Use a tight settle to keep the test fast. + port.flush_chunks_until_idle(max_wait_ms=200, settle_ms=10) + + port.emit_chunks.assert_called_once_with(["a", "b"]) + # Buffer should now be drained (take_chunks was called inside flush). + assert port.take_chunks() == [] + + +def test_flush_until_idle_hard_deadline_never_resets(): + """max_wait_ms is a hard absolute cap measured from function entry. + + Even if new chunks keep arriving, the worker must break within + max_wait_ms. Before the fix the deadline was re-set after every + drained chunk — that could keep the worker spinning indefinitely. + """ + port = _make_port() + port.add_chunk("initial") + + # monotonic timeline: 0.0 (start), 0.010, 0.020, 0.030 (past 30ms cap), 0.030 + fake_monotonic = iter([0.0, 0.010, 0.020, 0.030, 0.030]) + + # emit_chunks keeps re-seeding the buffer so it never empties — + # without a hard deadline the loop would never break. + def fake_emit(chunks): + port.add_chunk("still-more") + port.emit_chunks.side_effect = fake_emit + + with patch("services.human_interaction.runtime_port.time.monotonic", side_effect=lambda: next(fake_monotonic)), \ + patch("services.human_interaction.runtime_port.time.sleep") as mock_sleep: + # Hard cap 30ms, settle 500ms. settle is unreachable because the + # buffer is never empty — we rely purely on the hard_deadline. + port.flush_chunks_until_idle(max_wait_ms=30, settle_ms=500) + + # emit_chunks was called a few times but NOT infinitely — the hard + # deadline cut it off. + assert port.emit_chunks.call_count >= 1 + # The last sleep call should be clipped to the remaining time (≤30ms). + if mock_sleep.call_args_list: + last_sleep = mock_sleep.call_args_list[-1].args[0] + assert last_sleep <= 0.031 # ≤ 30ms with tiny float slack + + +def test_flush_until_idle_timeout_wins_over_settle(): + """When max_wait_ms expires before an idle settle, we still break. + + Mock monotonic so time appears to advance past the deadline on the + second poll — settle_ms is long but max_wait is short, so timeout wins. + """ + port = _make_port() + port.add_chunk("initial") + + # monotonic sequence: 0.0, 0.01 (still inside 30ms window), 0.05 (past deadline) + fake_monotonic = iter([0.0, 0.010, 0.050, 0.050]) + + with patch("services.human_interaction.runtime_port.time.monotonic", side_effect=lambda: next(fake_monotonic)), \ + patch("services.human_interaction.runtime_port.time.sleep"): + # max_wait=30ms, settle=500ms. First call sees chunks → emit, deadline=0.030. + # Second poll: now=0.050 > 0.030 → timeout break. + port.flush_chunks_until_idle(max_wait_ms=30, settle_ms=500) + + # emit_chunks called exactly once — we drained the initial chunk, then timeout fired. + assert port.emit_chunks.call_count == 1 + assert port.emit_chunks.call_args.args[0] == ["initial"] + + +# --- HITL entry points invoke flush_chunks_until_idle ----------------------- + +def test_dispatch_calls_flush_before_transaction(): + port = _make_port() + # Make dispatch not actually open a transaction — we only care about flush. + port.flush_chunks_until_idle = MagicMock() + + # Patch the transaction contextmanager with one that yields a mock tx + # where pause_requested is False and we never hit ask_user path. + fake_tx = MagicMock() + fake_tx.run.pause_requested = False + fake_tx.execution.side_effect = MagicMock(status="SUCCEEDED") + fake_tx.requests.return_value = [] + port.transaction = MagicMock() + port.transaction.return_value = contextmanager(lambda: iter([fake_tx]))() + + # Interaction path needs payload with questions for the early-return + # "already answered" branches — let's use no interaction so we fall + # through to the "not suspended → emit STARTED" case. + from nexent.core.human_interaction.contracts import AttemptSuspended + + # We expect either normal return or AttemptSuspended; both are fine as + # long as flush_chunks_until_idle is called before any DB work. + try: + port.dispatch(0, "tool_a", {"x": 1}) + except AttemptSuspended: + pass + except Exception: + # Any other exception from the mocked tx is acceptable — we only + # care that flush was called. + pass + + port.flush_chunks_until_idle.assert_called_once() + + +def test_finish_calls_flush_before_transaction(): + port = _make_port() + port.flush_chunks_until_idle = MagicMock() + port.transaction = MagicMock() + fake_tx = MagicMock() + fake_tx.run.status = "RUNNING" + port.transaction.return_value = contextmanager(lambda: iter([fake_tx]))() + + port.finish("COMPLETED") + port.flush_chunks_until_idle.assert_called_once() + + +def test_boundary_calls_flush_before_transaction(): + port = _make_port() + port.flush_chunks_until_idle = MagicMock() + port.transaction = MagicMock() + fake_tx = MagicMock() + fake_tx.run.pause_requested = False + port.transaction.return_value = contextmanager(lambda: iter([fake_tx]))() + + try: + port.boundary({}) + except AttemptSuspended: + pass + + port.flush_chunks_until_idle.assert_called_once() + + +def test_receipt_calls_flush_before_transaction(): + port = _make_port() + port.flush_chunks_until_idle = MagicMock() + port.transaction = MagicMock() + fake_tx = MagicMock() + fake_exec = MagicMock() + fake_exec.status = "STARTED" + fake_tx.execution.return_value = fake_exec + port.transaction.return_value = contextmanager(lambda: iter([fake_tx]))() + + port.receipt(0, {"ok": True}) + port.flush_chunks_until_idle.assert_called_once() + + +def test_wait_until_ready_calls_flush_before_status_transition(): + """_wait_until_ready → flush_chunks_until_idle before transitioning to RUNNING.""" + from contextlib import contextmanager + + port = _make_port() + port.flush_chunks_until_idle = MagicMock() + + fake_tx = MagicMock() + fake_tx.run.status = "READY" + fake_tx.run.fence = "fence-1" + fake_tx.run.lock_owner = "worker" + + @contextmanager + def fake_repo_transaction(*args, **kwargs): + yield fake_tx + + port.repository.transaction = fake_repo_transaction + port.service = MagicMock() # _wait_until_ready calls service._expire(tx) + + # Patch utcnow so that the "lock_until is None or <= utcnow" check passes. + from datetime import datetime, timezone + fake_tx.run.lock_until = datetime(2099, 1, 1, tzinfo=timezone.utc) + + with patch("services.human_interaction.runtime_port.utcnow", return_value=datetime(2025, 1, 1, tzinfo=timezone.utc)), \ + patch("services.human_interaction.runtime_port.time.sleep"): + port._wait_until_ready() + + port.flush_chunks_until_idle.assert_called_once() + assert fake_tx.run.status == "RUNNING" + + +# --- in-flight emit and failure recovery -------------------------------------- + +def test_flush_until_idle_treats_in_flight_emit_as_busy(): + """An empty buffer is NOT idle while an async emit is handing chunks to + the DB thread — flush keeps polling until the emit completes. + """ + port = _make_port() + port.emit_chunks = MagicMock() + port._emit_in_flight.set() + result: dict = {} + + def clear_soon(): + time.sleep(0.06) + port._emit_in_flight.clear() + + threading.Thread(target=clear_soon, daemon=True).start() + + def run_flush(): + started = time.monotonic() + port.flush_chunks_until_idle(max_wait_ms=2000, settle_ms=10) + result["elapsed"] = time.monotonic() - started + + worker = threading.Thread(target=run_flush, daemon=True) + worker.start() + worker.join(timeout=5) + + assert not worker.is_alive(), "flush_chunks_until_idle blocked past hard deadline" + # Without the in-flight guard the flush would settle at ~10ms on the + # empty buffer; observing >= 50ms proves it waited for the emit. + assert result["elapsed"] >= 0.05, result + assert not port._emit_in_flight.is_set() + + +def test_flush_until_idle_restores_chunks_when_emit_chunks_raises(): + """A failed DB emit must not lose drained chunks — they go back to the + shared buffer for the next flush attempt. + """ + port = _make_port() + port.emit_chunks = MagicMock(side_effect=RuntimeError("db down")) + port.add_chunk("c1") + port.add_chunk("c2") + + with pytest.raises(RuntimeError, match="db down"): + port.flush_chunks_until_idle(max_wait_ms=200, settle_ms=10) + + assert port.peek_chunks() == 2 + assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + + +def test_finish_flush_failure_still_writes_terminal_status(): + """A chunk-flush failure inside finish must not prevent the terminal + status row (and its human_run event) from being written. + """ + port = _make_port() + port.flush_chunks_until_idle = MagicMock(side_effect=RuntimeError("flush failed")) + port.transaction = MagicMock() + fake_tx = MagicMock() + fake_tx.run.status = "RUNNING" + fake_tx.requests.return_value = [] + port.transaction.return_value = contextmanager(lambda: iter([fake_tx]))() + + port.finish("COMPLETED") + + port.flush_chunks_until_idle.assert_called_once() + assert fake_tx.run.status == "COMPLETED" + fake_tx.emit.assert_called_once() diff --git a/test/backend/services/test_runtime_proxy_service.py b/test/backend/services/test_runtime_proxy_service.py index 0c8b20f5b7..ce43de09e5 100644 --- a/test/backend/services/test_runtime_proxy_service.py +++ b/test/backend/services/test_runtime_proxy_service.py @@ -142,6 +142,134 @@ def test_authorization_headers_maps_missing_jwt_configuration(monkeypatch): proxy._authorization_headers("user-a", "tenant-a") +@pytest.mark.asyncio +async def test_forward_agent_evaluation_trial_run_posts_runtime_request(monkeypatch): + captured = {} + + async def handler(request: httpx.Request): + captured["request"] = request + return httpx.Response(200, json={"answer": "ok", "scores": {"judge": 1.0}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr(proxy, "RUNTIME_SERVICE_URL", "http://runtime:5014") + monkeypatch.setattr(proxy, "generate_internal_runtime_jwt", lambda *_: "jwt") + + def create_client(**kwargs): + client.headers.update(kwargs["headers"]) + return client + + monkeypatch.setattr(proxy, "create_httpx_client", create_client) + + result = await proxy.forward_agent_evaluation_trial_run( + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=[5], + language="zh", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert result == {"answer": "ok", "scores": {"judge": 1.0}} + request = captured["request"] + assert str(request.url) == "http://runtime:5014/api/agent-evaluations/internal/trial-run" + assert request.headers["authorization"] == "Bearer jwt" + assert json.loads(request.content) == { + "agent_id": 7, + "agent_version_no": 3, + "query": "hello", + "judge_model_id": 99, + "evaluator_ids": [5], + "language": "zh", + } + assert client.is_closed is True + + +@pytest.mark.asyncio +async def test_forward_agent_evaluation_trial_run_maps_upstream_error(monkeypatch): + client = httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response(500, content=b'{"detail":"failed"}') + ) + ) + monkeypatch.setattr(proxy, "generate_internal_runtime_jwt", lambda *_: "jwt") + monkeypatch.setattr(proxy, "create_httpx_client", lambda **_: client) + + with pytest.raises(RuntimeUpstreamError) as exc_info: + await proxy.forward_agent_evaluation_trial_run( + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=None, + language="zh", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert exc_info.value.status_code == 500 + assert client.is_closed is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("transport_error", "expected_error"), + [ + (httpx.ReadTimeout("timed out"), RuntimeServiceTimeoutError), + (httpx.ConnectError("connection failed"), RuntimeServiceUnavailableError), + ], +) +async def test_forward_agent_evaluation_trial_run_maps_transport_errors( + monkeypatch, transport_error, expected_error, +): + async def handler(request: httpx.Request): + transport_error.request = request + raise transport_error + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr(proxy, "generate_internal_runtime_jwt", lambda *_: "jwt") + monkeypatch.setattr(proxy, "create_httpx_client", lambda **_: client) + + with pytest.raises(expected_error): + await proxy.forward_agent_evaluation_trial_run( + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=None, + language="zh", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert client.is_closed is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("content", [b"not-json", b"[]"]) +async def test_forward_agent_evaluation_trial_run_rejects_invalid_success_payload(monkeypatch, content): + client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda _: httpx.Response(200, content=content)) + ) + monkeypatch.setattr(proxy, "generate_internal_runtime_jwt", lambda *_: "jwt") + monkeypatch.setattr(proxy, "create_httpx_client", lambda **_: client) + + with pytest.raises(RuntimeServiceUnavailableError): + await proxy.forward_agent_evaluation_trial_run( + agent_id=7, + agent_version_no=3, + query="hello", + judge_model_id=99, + evaluator_ids=None, + language="zh", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert client.is_closed is True + + @pytest.mark.asyncio async def test_forward_agent_run_streams_body_and_closes_resources(monkeypatch): stream = TrackingStream([b"data: one\n\n", b"data: two\n\n"]) diff --git a/test/common/openai_compatible_mock_server.py b/test/common/openai_compatible_mock_server.py new file mode 100644 index 0000000000..0f24c246f6 --- /dev/null +++ b/test/common/openai_compatible_mock_server.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Deterministic OpenAI-compatible server for Nexent deployment tests. + +The server is deliberately self-contained and never forwards requests to a +real provider. It implements the OpenAI surfaces used by Nexent and exposes a +small control API for selecting retry/failure scenarios. +""" + +from __future__ import annotations + +import argparse +import json +import re +import socket +import threading +import time +import uuid +from dataclasses import dataclass, field +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import urlsplit + + +SUPPORTED_SCENARIOS = { + "success", + "partial_then_success", + "always_429", + "always_503", + "always_401", + "empty_stop", + "length", +} + + +@dataclass +class MockState: + scenario: str = "success" + response_text: str = "MOCK_SUCCESS" + retry_after: float = 0.0 + partial_chunk_delay: float = 0.05 + request_count: int = 0 + requests: list[dict[str, Any]] = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def configure(self, payload: dict[str, Any]) -> dict[str, Any]: + scenario = payload.get("scenario", "success") + if scenario not in SUPPORTED_SCENARIOS: + raise ValueError(f"unsupported scenario: {scenario}") + with self.lock: + self.scenario = scenario + self.response_text = str(payload.get("response_text", "MOCK_SUCCESS")) + self.retry_after = max(0.0, float(payload.get("retry_after", 0.0))) + self.partial_chunk_delay = max( + 0.0, float(payload.get("partial_chunk_delay", 0.05)) + ) + self.request_count = 0 + self.requests.clear() + return self._snapshot_unlocked() + + def record(self, payload: dict[str, Any], has_bearer_auth: bool) -> tuple[int, str]: + with self.lock: + self.request_count += 1 + request_number = self.request_count + self.requests.append( + { + "request_number": request_number, + "model": payload.get("model"), + "stream": payload.get("stream") is True, + "include_usage": ( + isinstance(payload.get("stream_options"), dict) + and payload["stream_options"].get("include_usage") is True + ), + "message_count": len(payload.get("messages", [])), + "max_tokens": payload.get("max_tokens"), + "stop_count": len(payload.get("stop", []) or []), + "has_bearer_auth": has_bearer_auth, + } + ) + return request_number, self.scenario + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return self._snapshot_unlocked() + + def _snapshot_unlocked(self) -> dict[str, Any]: + return { + "scenario": self.scenario, + "response_text": self.response_text, + "retry_after": self.retry_after, + "request_count": self.request_count, + "requests": list(self.requests), + } + + +def _extract_requested_answer(payload: dict[str, Any], fallback: str) -> str: + messages = payload.get("messages") + if not isinstance(messages, list): + return fallback + text_parts: list[str] = [] + for message in reversed(messages): + if not isinstance(message, dict) or message.get("role") != "user": + continue + content = message.get("content") + if isinstance(content, str): + text_parts.append(content) + elif isinstance(content, list): + text_parts.extend( + str(item.get("text", "")) + for item in content + if isinstance(item, dict) and item.get("type") == "text" + ) + break + user_text = "\n".join(text_parts) + match = re.search( + r"reply\s+with\s+exactly\s+([A-Za-z0-9_.:-]+)", user_text, re.IGNORECASE + ) + return match.group(1) if match else fallback + + +class OpenAICompatibleMockHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "NexentOpenAICompatibleMock/1.0" + + @property + def state(self) -> MockState: + return self.server.mock_state # type: ignore[attr-defined] + + def log_message(self, _format: str, *_args: Any) -> None: + return + + def _send_bytes( + self, status: int, body: bytes, content_type: str = "application/json" + ) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("x-request-id", f"mock-{uuid.uuid4().hex}") + self.end_headers() + self.wfile.write(body) + + def _send_json(self, status: int, payload: dict[str, Any]) -> None: + self._send_bytes(status, json.dumps(payload).encode()) + + def _read_json(self) -> dict[str, Any]: + try: + length = int(self.headers.get("Content-Length", "0")) + payload = json.loads(self.rfile.read(length) or b"{}") + except (ValueError, json.JSONDecodeError) as exc: + raise ValueError("request body must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract + path = urlsplit(self.path).path + if path == "/__stats": + self._send_json(HTTPStatus.OK, self.state.snapshot()) + return + if path in {"/v1/models", "/models"}: + self._send_json( + HTTPStatus.OK, + { + "object": "list", + "data": [ + { + "id": "nexent-mock-model", + "object": "model", + "created": 0, + "owned_by": "nexent-test", + } + ], + }, + ) + return + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "Unknown endpoint") + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract + path = urlsplit(self.path).path + try: + payload = self._read_json() + except ValueError as exc: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request_error", str(exc)) + return + + if path == "/__control": + try: + snapshot = self.state.configure(payload) + except (TypeError, ValueError) as exc: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request_error", str(exc)) + return + self._send_json(HTTPStatus.OK, snapshot) + return + + if path not in {"/v1/chat/completions", "/chat/completions"}: + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "Unknown endpoint") + return + if not isinstance(payload.get("model"), str) or not isinstance( + payload.get("messages"), list + ): + self._send_error( + HTTPStatus.BAD_REQUEST, + "invalid_request_error", + "model and messages are required", + ) + return + + has_bearer_auth = self.headers.get("Authorization", "").startswith("Bearer ") + request_number, scenario = self.state.record(payload, has_bearer_auth) + if scenario == "always_429": + self._send_error( + HTTPStatus.TOO_MANY_REQUESTS, + "rate_limit_error", + "Injected rate limit", + retry_after=self.state.retry_after, + ) + return + if scenario == "always_503": + self._send_error( + HTTPStatus.SERVICE_UNAVAILABLE, + "server_error", + "Injected temporary outage", + retry_after=self.state.retry_after, + ) + return + if scenario == "always_401": + self._send_error( + HTTPStatus.UNAUTHORIZED, + "authentication_error", + "Injected authentication failure", + ) + return + if scenario == "partial_then_success" and request_number == 1: + self._send_partial_stream(payload) + return + + finish_reason = "length" if scenario == "length" else "stop" + answer = "" if scenario == "empty_stop" else _extract_requested_answer( + payload, self.state.response_text + ) + if payload.get("stream") is True: + self._send_complete_stream(payload, answer, finish_reason) + else: + self._send_non_stream_response(payload, answer, finish_reason) + + def _send_error( + self, + status: int, + error_type: str, + message: str, + *, + retry_after: float | None = None, + ) -> None: + body = json.dumps( + { + "error": { + "message": message, + "type": error_type, + "param": None, + "code": error_type, + } + } + ).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("x-request-id", f"mock-{uuid.uuid4().hex}") + if retry_after is not None: + self.send_header("Retry-After", str(retry_after)) + self.end_headers() + self.wfile.write(body) + + @staticmethod + def _completion_chunk( + *, + request_id: str, + model: str, + delta: dict[str, Any], + finish_reason: str | None, + usage: dict[str, int] | None = None, + choices: bool = True, + ) -> bytes: + payload: dict[str, Any] = { + "id": request_id, + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": ( + [{"index": 0, "delta": delta, "finish_reason": finish_reason}] + if choices + else [] + ), + } + if usage is not None: + payload["usage"] = usage + return f"data: {json.dumps(payload)}\n\n".encode() + + def _send_complete_stream( + self, payload: dict[str, Any], answer: str, finish_reason: str + ) -> None: + request_id = f"chatcmpl-mock-{uuid.uuid4().hex}" + model = payload["model"] + code = f'final_answer({json.dumps(answer)})' if answer else "" + chunks = [ + self._completion_chunk( + request_id=request_id, + model=model, + delta={"role": "assistant", "content": ""}, + finish_reason=None, + ), + self._completion_chunk( + request_id=request_id, + model=model, + delta={"reasoning_content": "Deterministic mock reasoning. "}, + finish_reason=None, + ), + ] + chunks.extend( + self._completion_chunk( + request_id=request_id, + model=model, + delta={"content": code[index : index + 8]}, + finish_reason=None, + ) + for index in range(0, len(code), 8) + ) + chunks.extend( + [ + self._completion_chunk( + request_id=request_id, + model=model, + delta={}, + finish_reason=finish_reason, + ), + self._completion_chunk( + request_id=request_id, + model=model, + delta={}, + finish_reason=None, + usage={ + "prompt_tokens": 100, + "completion_tokens": max(1, len(code) // 4), + "total_tokens": 100 + max(1, len(code) // 4), + }, + choices=False, + ), + b"data: [DONE]\n\n", + ] + ) + self._send_bytes(HTTPStatus.OK, b"".join(chunks), "text/event-stream") + + def _send_partial_stream(self, payload: dict[str, Any]) -> None: + request_id = f"chatcmpl-mock-{uuid.uuid4().hex}" + model = payload["model"] + chunks = [ + self._completion_chunk( + request_id=request_id, + model=model, + delta={"reasoning_content": f"CMSR_LEAK_{index:02d}"}, + finish_reason=None, + ) + for index in range(12) + ] + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.send_header("x-request-id", f"mock-{uuid.uuid4().hex}") + self.end_headers() + for chunk in chunks: + self.wfile.write(f"{len(chunk):X}\r\n".encode() + chunk + b"\r\n") + self.wfile.flush() + time.sleep(self.state.partial_chunk_delay) + self.close_connection = True + try: + self.connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.connection.close() + + def _send_non_stream_response( + self, payload: dict[str, Any], answer: str, finish_reason: str + ) -> None: + code = f'final_answer({json.dumps(answer)})' if answer else "" + self._send_json( + HTTPStatus.OK, + { + "id": f"chatcmpl-mock-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": payload["model"], + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "reasoning_content": "Deterministic mock reasoning. ", + "content": code, + }, + "finish_reason": finish_reason, + } + ], + "usage": { + "prompt_tokens": 100, + "completion_tokens": max(1, len(code) // 4), + "total_tokens": 100 + max(1, len(code) // 4), + }, + }, + ) + + +class OpenAICompatibleMockServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address: tuple[str, int], state: MockState | None = None): + self.mock_state = state or MockState() + super().__init__(address, OpenAICompatibleMockHandler) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=18080) + args = parser.parse_args() + server = OpenAICompatibleMockServer((args.host, args.port)) + print(f"openai_compatible_mock_ready={args.host}:{args.port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/test/common/test_openai_compatible_mock_server.py b/test/common/test_openai_compatible_mock_server.py new file mode 100644 index 0000000000..65feb7166d --- /dev/null +++ b/test/common/test_openai_compatible_mock_server.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import http.client +import json +import threading +import urllib.error +import urllib.request + +import pytest + +from test.common.openai_compatible_mock_server import OpenAICompatibleMockServer + + +@pytest.fixture() +def mock_server(): + server = OpenAICompatibleMockServer(("127.0.0.1", 0)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server, server.server_address[1] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _post_json(port: int, path: str, payload: dict): + request = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=json.dumps(payload).encode(), + headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=3) as response: + return response.status, dict(response.headers), response.read() + + +def _stream_content(body: bytes) -> str: + content_parts = [] + for line in body.decode().splitlines(): + if not line.startswith("data: ") or line == "data: [DONE]": + continue + payload = json.loads(line[6:]) + for choice in payload.get("choices", []): + content = choice.get("delta", {}).get("content") + if content: + content_parts.append(content) + return "".join(content_parts) + + +def test_cmsr_mock_stream_matches_nexent_openai_contract(mock_server): + _server, port = mock_server + status, _headers, body = _post_json( + port, + "/v1/chat/completions", + { + "model": "nexent-mock-model", + "messages": [{"role": "user", "content": "Reply with exactly CONTRACT_OK"}], + "stream": True, + "stream_options": {"include_usage": True}, + "max_tokens": 512, + "stop": ["Observation:"], + }, + ) + + assert status == 200 + assert b'"object": "chat.completion.chunk"' in body + assert b'"reasoning_content": "Deterministic mock reasoning. "' in body + assert _stream_content(body) == 'final_answer("CONTRACT_OK")' + assert b'"finish_reason": "stop"' in body + assert b'"prompt_tokens": 100' in body + assert body.endswith(b"data: [DONE]\n\n") + + stats = _server.mock_state.snapshot() + assert stats["request_count"] == 1 + assert stats["requests"] == [ + { + "request_number": 1, + "model": "nexent-mock-model", + "stream": True, + "include_usage": True, + "message_count": 1, + "max_tokens": 512, + "stop_count": 1, + "has_bearer_auth": True, + } + ] + + +@pytest.mark.parametrize( + ("scenario", "expected_status", "expected_type"), + [ + ("always_429", 429, "rate_limit_error"), + ("always_503", 503, "server_error"), + ("always_401", 401, "authentication_error"), + ], +) +def test_cmsr_mock_standard_error_contract( + mock_server, scenario, expected_status, expected_type +): + _server, port = mock_server + _post_json(port, "/__control", {"scenario": scenario, "retry_after": 0.25}) + + with pytest.raises(urllib.error.HTTPError) as caught: + _post_json( + port, + "/v1/chat/completions", + {"model": "nexent-mock-model", "messages": [], "stream": True}, + ) + + assert caught.value.code == expected_status + error = json.loads(caught.value.read()) + assert error["error"]["type"] == expected_type + if expected_status in {429, 503}: + assert caught.value.headers["Retry-After"] == "0.25" + + +def test_cmsr_mock_partial_then_success_is_deterministic(mock_server): + server, port = mock_server + _post_json( + port, + "/__control", + { + "scenario": "partial_then_success", + "response_text": "RECOVERED", + "partial_chunk_delay": 0, + }, + ) + request_body = json.dumps( + {"model": "nexent-mock-model", "messages": [], "stream": True} + ) + + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) + connection.request( + "POST", + "/v1/chat/completions", + body=request_body, + headers={"Content-Type": "application/json", "Authorization": "Bearer test"}, + ) + response = connection.getresponse() + with pytest.raises(http.client.IncompleteRead) as caught: + response.read() + assert b"CMSR_LEAK_00" in caught.value.partial + assert b"CMSR_LEAK_11" in caught.value.partial + connection.close() + + status, _headers, body = _post_json( + port, + "/v1/chat/completions", + {"model": "nexent-mock-model", "messages": [], "stream": True}, + ) + assert status == 200 + assert _stream_content(body) == 'final_answer("RECOVERED")' + assert server.mock_state.snapshot()["request_count"] == 2 + + +def test_cmsr_mock_non_stream_and_models_contract(mock_server): + _server, port = mock_server + with urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=3) as response: + models = json.loads(response.read()) + assert models["data"][0]["id"] == "nexent-mock-model" + + status, _headers, body = _post_json( + port, + "/v1/chat/completions", + {"model": "nexent-mock-model", "messages": [], "stream": False}, + ) + completion = json.loads(body) + assert status == 200 + assert completion["object"] == "chat.completion" + assert completion["choices"][0]["finish_reason"] == "stop" + assert completion["usage"]["total_tokens"] > 100 diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index 742133bdf9..676ae6e7a1 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -9,8 +9,22 @@ - DELETE /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id} (delete) - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Upload (upload docs) - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles (list docs) + - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Remove (remove docs) + - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Download (download doc) + - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Channels (ingestion channels) + - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/History (all-status file history) - POST /KnowledgeBase/Tenants/{tenant}/Retrieval/FusionSearch (search - preserved from reference) +Document status simulation (drives the "processing status" UI): + * Uploaded documents start as ``PROCESSING`` and flip to ``COMPLETED`` once + ``_PROCESSING_SECONDS`` have elapsed, so polling behaviour can be observed + end to end. Tune it with ``POST /_mock/processing-seconds?seconds=N``. + * ``POST /_mock/doc-status`` (body ``{kds_id, file_ino_no, status}``) forces one + document into any status without waiting for the timer, including the + non-terminal ``UPLOADING`` / ``EXTRACTING`` stages. + * ``GET .../KnowledgeFiles`` keeps returning COMPLETED documents only (mirrors + real AIDP), while ``POST .../KnowledgeFiles/History`` returns every status. + Knowledge base + document state is persisted to ``_state/knowledge_bases.json`` (next to this file). On restart the mock loads the file, so KBs created by tests or frontend sessions survive across restarts without re-creation @@ -22,16 +36,18 @@ import argparse import json import logging +import mimetypes import os import time import uuid from pathlib import Path from typing import Any, Dict, List, Literal, Optional +from urllib.parse import quote from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, StreamingResponse logger = logging.getLogger("aidp_mgmt_mock") logging.basicConfig( @@ -57,6 +73,23 @@ _KB_PREFIX = f"/KnowledgeBase/Tenants/{TENANT}/KnowledgeBases" _MODELS_PREFIX = f"/ModelService/Tenants/{TENANT}/Service" +# Document status constants mirroring the real AIDP vocabulary. +STATUS_PROCESSING = "PROCESSING" +STATUS_COMPLETED = "COMPLETED" +STATUS_FAILED = "FAILED" +_TERMINAL_STATUSES = {STATUS_COMPLETED, STATUS_FAILED} + +# One ingestion channel per knowledge base, exposed at the KB-scoped path the +# real AIDP uses (``.../KnowledgeBases/{kds_id}/Channels``). ``src_dir`` embeds +# the KB id, which is how this mock maps a History ``dir_path`` back to its +# documents. The tenant-scoped variant is deliberately NOT served: a wrong path +# in the adapter must fail here exactly as it fails against AIDP. +_CHANNEL_ROOT = "/aidp/knowledge" + +# Seconds an uploaded document stays PROCESSING before turning COMPLETED. +# Overridable at runtime through POST /_mock/processing-seconds. +_PROCESSING_SECONDS = 8.0 + # Directory for persisted runtime state. Lives next to this file so the mock # is self-contained (no absolute paths) and stays out of version control via # ``.gitignore``. Only KB + document state is persisted; failure-injection @@ -118,14 +151,16 @@ def _seed_initial_data() -> None: # Seed some documents for the FAQ KB so list_docs is non-empty by default. _DOCUMENTS_BY_KB["aidp-kb-faq"] = [ { - "file_ino_no": "file-faq-001", + "file_uuid": "00000000-0000-4000-8000-000000000001", + "file_ino_no": 1001, "file_name": "常见问题汇总.txt", "file_size": 2048, "file_type": "txt", "create_time": 1718000400, }, { - "file_ino_no": "file-faq-002", + "file_uuid": "00000000-0000-4000-8000-000000000002", + "file_ino_no": 1002, "file_name": "troubleshooting.md", "file_size": 4096, "file_type": "md", @@ -192,7 +227,99 @@ def _load_state() -> None: ) +def _ensure_document_uuids() -> None: + """Backfill stable UUIDs for state created before UUID support existed.""" + for kds_id, documents in _DOCUMENTS_BY_KB.items(): + if not isinstance(documents, list): + continue + for document in documents: + if not isinstance(document, dict) or document.get("file_uuid"): + continue + file_ino_no = str(document.get("file_ino_no") or uuid.uuid4()) + document["file_uuid"] = str( + uuid.uuid5(uuid.NAMESPACE_URL, f"mock-aidp:{kds_id}:{file_ino_no}") + ) + + _load_state() +_ensure_document_uuids() +_save_state() + + +def _public_document(document: Dict[str, Any]) -> Dict[str, Any]: + """Return document metadata without any mock-only private fields.""" + return {key: value for key, value in document.items() if not key.startswith("_")} + + +def _find_document(kds_id: str, file_uuid: str) -> Optional[Dict[str, Any]]: + return next( + ( + document + for document in _DOCUMENTS_BY_KB.get(kds_id, []) + if document.get("file_uuid") == file_uuid + ), + None, + ) + + +def _document_content(document: Dict[str, Any]) -> bytes: + """Build deterministic mock content for a document download.""" + return ( + f"Mock AIDP content for {document.get('file_name', 'download')}\n" + ).encode("utf-8") + + +def _content_disposition(filename: str) -> str: + """Build a standard ASCII fallback plus RFC 5987 UTF-8 filename header.""" + ascii_name = "".join( + char if 32 <= ord(char) < 127 and char not in {'"', "\\"} else "_" + for char in filename + ) + return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quote(filename)}' + + +# ============================================================================= +# Document status helpers +# ============================================================================= +def _channel_src_dir(kds_id: str) -> str: + """Return the source directory of a knowledge base's ingestion channel.""" + return f"{_CHANNEL_ROOT}/{kds_id}" + + +def _kds_id_from_dir_path(dir_path: Optional[str]) -> Optional[str]: + """Reverse ``_channel_src_dir`` so a History request maps back to one KB.""" + if not isinstance(dir_path, str): + return None + prefix = f"{_CHANNEL_ROOT}/" + if not dir_path.startswith(prefix): + return None + return dir_path[len(prefix):].strip("/") or None + + +def _doc_effective_status(doc: Dict[str, Any]) -> str: + """Return the document's current status, advancing the processing timer. + + Documents persisted before status simulation existed (and the seed data) + carry no status at all and are treated as already ingested. + """ + status = doc.get("status") + if status is None or status == "": + return STATUS_COMPLETED + if status == STATUS_PROCESSING: + deadline = doc.get("processing_until") + if isinstance(deadline, (int, float)) and time.time() < deadline: + return STATUS_PROCESSING + return STATUS_COMPLETED + return str(status) + + +def _visible_in_completed_listing(doc: Dict[str, Any]) -> bool: + """Whether a document appears in the legacy completed-files listing. + + Real AIDP only exposes ingested files there; files still being processed are + invisible, which is exactly the behaviour the history endpoint replaces. + """ + return _doc_effective_status(doc) == STATUS_COMPLETED # ============================================================================= @@ -211,6 +338,23 @@ class UpdateKbBody(BaseModel): description: Optional[str] = None +class DocHistoryBody(BaseModel): + """Body of POST .../KnowledgeFiles/History (channel + directory scoped).""" + + fs_id: Optional[str] = None + dir_path: Optional[str] = None + + +class DocStatusBody(BaseModel): + """Body of POST /_mock/doc-status (test control over one document).""" + + kds_id: str + file_ino_no: str + status: Literal[ + "UPLOADING", "PROCESSING", "EXTRACTING", "COMPLETED", "FAILED" + ] = "FAILED" + + class MetadataCondition(BaseModel): logical_operator: Literal["and", "or"] = "and" conditions: List[Dict[str, Any]] = Field(default_factory=list) @@ -230,6 +374,14 @@ class FusionSearchRequest(BaseModel): metadata_condition: Optional[MetadataCondition] = None +class RemoveFilesBody(BaseModel): + file_uuids: List[uuid.UUID] = Field(..., min_length=1) + + +class DownloadFileBody(BaseModel): + file_uuid: uuid.UUID = Field(...) + + # ============================================================================= # Auth helper # ============================================================================= @@ -322,6 +474,44 @@ def reset_failures() -> JSONResponse: }) +@app.post("/_mock/processing-seconds") +def set_processing_seconds( + seconds: float = Query(8.0, ge=0.0, le=600.0, description="Seconds a new upload stays PROCESSING"), +) -> JSONResponse: + """Tune how long newly uploaded documents stay PROCESSING. + + Set 0 to make uploads complete immediately, or a large value to keep the + frontend's status polling running while you inspect it. + """ + global _PROCESSING_SECONDS + _PROCESSING_SECONDS = seconds + logger.info("MOCK CONFIG processing seconds = %s", seconds) + return JSONResponse(content={"processing_seconds": _PROCESSING_SECONDS}) + + +@app.post("/_mock/doc-status") +def force_doc_status(body: DocStatusBody) -> JSONResponse: + """Force one document into a given status (used to render a stage in the UI).""" + docs = _DOCUMENTS_BY_KB.get(body.kds_id, []) + for doc in docs: + # Compare as strings: document ids are numeric in some state files and + # strings in others, and callers should not have to care. + if str(doc.get("file_ino_no")) == str(body.file_ino_no): + doc["status"] = body.status + doc.pop("processing_until", None) + _save_state() + logger.info( + "MOCK CONFIG kds_id=%s file=%s status=%s", + body.kds_id, body.file_ino_no, body.status, + ) + return JSONResponse(content={"doc": {**doc, "status": body.status}}) + + raise HTTPException( + status_code=404, + detail=f"Document {body.file_ino_no} not found in {body.kds_id}", + ) + + # ============================================================================= # Knowledge Base CRUD # ============================================================================= @@ -331,12 +521,26 @@ def reset_failures() -> JSONResponse: def list_knowledge_bases( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(default=None), authorization: Optional[str] = Header(default=None), ) -> JSONResponse: - """List knowledge bases with pagination + next_link (matches AIDP shape).""" + """List knowledge bases with pagination + next_link (matches AIDP shape). + + ``keyword`` narrows the set by knowledge-base name (case-insensitive), the + way the real AIDP list endpoint does. Without this the mock returned every + KB for a filtered request, which made the search feature look broken in + local runs once the page stopped filtering client-side. + """ _check_auth(authorization) all_items = list(_KNOWLEDGE_BASES.values()) + normalized_keyword = (keyword or "").strip().lower() + if normalized_keyword: + all_items = [ + kb + for kb in all_items + if normalized_keyword in str(kb.get("kds_name") or "").lower() + ] start = (page - 1) * page_size end = start + page_size # Enrich each item with document_count (same as detail endpoint does) @@ -380,7 +584,11 @@ def count_documents( _check_auth(authorization) if kds_id not in _KNOWLEDGE_BASES: raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") - count = len(_DOCUMENTS_BY_KB.get(kds_id, [])) + # Only ingested files count on the legacy endpoint, matching the list. + count = len([ + doc for doc in _DOCUMENTS_BY_KB.get(kds_id, []) + if _visible_in_completed_listing(doc) + ]) logger.info("COUNT DOCS kds_id=%s count=%d", kds_id, count) return JSONResponse(content={"count": count}) @@ -503,13 +711,26 @@ async def upload_documents( for f in files: try: content = await f.read() - file_ino_no = f"file-{uuid.uuid4().hex[:12]}" + file_ino_no = max( + ( + document["file_ino_no"] + for document in _DOCUMENTS_BY_KB.get(kds_id, []) + if isinstance(document.get("file_ino_no"), int) + ), + default=0, + ) + 1 doc = { + "file_uuid": str(uuid.uuid4()), "file_ino_no": file_ino_no, "file_name": f.filename or "unknown", "file_size": len(content), "file_type": (f.filename.rsplit(".", 1)[-1] if f.filename and "." in f.filename else "bin"), "create_time": int(time.time()), + # Uploaded files enter the ingestion pipeline immediately: the + # legacy list endpoint hides them until the timer elapses, while + # the history endpoint reports them as PROCESSING. + "status": STATUS_PROCESSING, + "processing_until": time.time() + _PROCESSING_SECONDS, } _DOCUMENTS_BY_KB.setdefault(kds_id, []).append(doc) success_docs.append(doc) @@ -535,6 +756,7 @@ async def upload_documents( "file_type": doc["file_type"], "file_size": doc["file_size"], "file_ino_no": doc["file_ino_no"], + "file_uuid": doc["file_uuid"], "first_upload_time": doc["create_time"], } for doc in success_docs @@ -550,16 +772,24 @@ def list_documents( page_size: int = Query(20, ge=1, le=100), authorization: Optional[str] = Header(default=None), ) -> JSONResponse: - """List documents in a knowledge base with pagination.""" + """List documents in a knowledge base with pagination. + + Only ingested (COMPLETED) files are returned: real AIDP hides files that are + still being chunked/embedded here, which is why the frontend used to show + nothing right after an upload. + """ _check_auth(authorization) if kds_id not in _KNOWLEDGE_BASES: raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") - all_docs = _DOCUMENTS_BY_KB.get(kds_id, []) + all_docs = [ + doc for doc in _DOCUMENTS_BY_KB.get(kds_id, []) + if _visible_in_completed_listing(doc) + ] start = (page - 1) * page_size end = start + page_size - items = all_docs[start:end] + items = [_public_document(doc) for doc in all_docs[start:end]] # Real AIDP returns `next_link` as the authoritative "more pages exist" # signal. When there are no more docs, next_link is simply absent. @@ -576,6 +806,172 @@ def list_documents( }) +@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Remove") +def remove_documents( + kds_id: str, + body: RemoveFilesBody, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Remove documents by file UUID and return per-file success/failure lists.""" + _check_auth(authorization) + + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + + documents = _DOCUMENTS_BY_KB.setdefault(kds_id, []) + remaining = list(documents) + success_list: List[Dict[str, str]] = [] + failed_list: List[Dict[str, str]] = [] + for raw_file_uuid in body.file_uuids: + file_uuid = str(raw_file_uuid) + matched = next( + (document for document in remaining if document.get("file_uuid") == file_uuid), + None, + ) + if matched is None: + failed_list.append({"file_uuid": file_uuid}) + continue + remaining.remove(matched) + success_list.append({"file_uuid": file_uuid}) + + _DOCUMENTS_BY_KB[kds_id] = remaining + _save_state() + logger.info( + "REMOVE DOCS kds_id=%s total=%d success=%d failed=%d", + kds_id, + len(body.file_uuids), + len(success_list), + len(failed_list), + ) + return JSONResponse(content={ + "summary": { + "total": len(body.file_uuids), + "success": len(success_list), + "failed": len(failed_list), + }, + "success_list": success_list, + "failed_list": failed_list, + }) + + +@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Download") +def download_document( + kds_id: str, + body: DownloadFileBody, + authorization: Optional[str] = Header(default=None), +) -> StreamingResponse: + """Return deterministic binary content for a document download.""" + _check_auth(authorization) + + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + + file_uuid = str(body.file_uuid) + document = _find_document(kds_id, file_uuid) + if document is None: + raise HTTPException(status_code=404, detail=f"File {file_uuid} not found") + + filename = str(document.get("file_name") or "download") + content = _document_content(document) + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + response_headers = { + "Content-Disposition": _content_disposition(filename), + "X-File-Size": str(len(content)), + } + async def content_stream(): + for offset in range(0, len(content), 8 * 1024): + yield content[offset : offset + 8 * 1024] + + return StreamingResponse( + content_stream(), + media_type=content_type, + headers=response_headers, + ) + + +# ============================================================================= +# Ingestion channels + knowledge-file history +# ============================================================================= + + +@app.get(f"{_KB_PREFIX}/{{kds_id}}/Channels") +def list_channels( + kds_id: str, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """List the ingestion channels feeding one knowledge base. + + The catalog is knowledge-base scoped, so a channel exposes the file-system + id and source directory the history endpoint is addressed with, plus + ``kds_id`` so a caller can associate the channel with its KB. + """ + _check_auth(authorization) + + kb = _KNOWLEDGE_BASES.get(kds_id) + if kb is None: + logger.info("LIST CHANNELS unknown kds_id=%s -> 404", kds_id) + return JSONResponse( + status_code=404, + content={"detail": f"Knowledge base {kds_id} not found"}, + ) + + items = [ + { + "fs_id": f"mock-fs-{kds_id}", + "src_dir": _channel_src_dir(kds_id), + "kds_id": kds_id, + "name": kb.get("kds_name"), + } + ] + logger.info("LIST CHANNELS kds_id=%s returned=%d", kds_id, len(items)) + return JSONResponse(content={"value": items}) + + +@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/History") +def knowledge_file_history( + kds_id: str, + body: DocHistoryBody, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """List every file in a channel directory, whatever its processing status. + + The endpoint is knowledge-base scoped, like the channel catalog and the + document list; the body still addresses the request to one channel directory + of that KB. Documents that are still PROCESSING are included with their live + status, and a directory pointing outside the KB answers with an empty list. + """ + _check_auth(authorization) + + if kds_id not in _KNOWLEDGE_BASES: + logger.info("FILE HISTORY unknown kds_id=%s -> 404", kds_id) + return JSONResponse( + status_code=404, + content={"detail": f"Knowledge base {kds_id} not found"}, + ) + + if _kds_id_from_dir_path(body.dir_path) != kds_id: + logger.info( + "FILE HISTORY dir_path=%r does not belong to kds_id=%s -> empty", + body.dir_path, + kds_id, + ) + return JSONResponse(content={"value": []}) + + items = [ + { + **doc, + "dir_path": _channel_src_dir(kds_id), + "status": _doc_effective_status(doc), + } + for doc in _DOCUMENTS_BY_KB.get(kds_id, []) + ] + logger.info( + "FILE HISTORY kds_id=%s fs_id=%s dir_path=%s returned=%d", + kds_id, body.fs_id, body.dir_path, len(items), + ) + return JSONResponse(content={"value": items}) + + # ============================================================================= # FusionSearch (preserved from reference mock) # ============================================================================= diff --git a/test/ext_components/aidp/test_aidp_access_service.py b/test/ext_components/aidp/test_aidp_access_service.py index a8a19fd834..07c2d345fa 100644 --- a/test/ext_components/aidp/test_aidp_access_service.py +++ b/test/ext_components/aidp/test_aidp_access_service.py @@ -370,3 +370,25 @@ def test_accessible_row_missing_kb_id_is_skipped(): assert snapshot.accessible_ids == [] assert snapshot.name_to_id == {} + + +def test_channels_cache_is_keyed_per_knowledge_base(): + """Channels are KB-scoped, so another KB must not reuse the cached answer.""" + loader = MagicMock(return_value={"value": [{"fs_id": "fs-1", "src_dir": "/dir/1"}]}) + + first = service.get_cached_aidp_channels( + "https://channels.example", "key", "kb-1", loader=loader + ) + second = service.get_cached_aidp_channels( + "https://channels.example", "key", "kb-1", loader=loader + ) + other = service.get_cached_aidp_channels( + "https://channels.example", "key", "kb-2", loader=loader + ) + + assert first == [{"fs_id": "fs-1", "src_dir": "/dir/1"}] + assert second == first + assert other == first + # One upstream load per KB: the repeat call is served from the cache, and the + # second KB gets its own entry instead of the first KB's channel. + assert loader.call_count == 2 diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py index 00ca05099a..bb845e2cba 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_app.py +++ b/test/ext_components/aidp/test_aidp_mgmt_app.py @@ -21,6 +21,7 @@ from typing import Any from unittest.mock import MagicMock, patch +import httpx import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -46,6 +47,11 @@ def _mod(name): nexent_storage_factory = _mod("nexent.storage.storage_client_factory") nexent_storage_factory.create_storage_client_from_config = MagicMock() +services_pkg = _mod("services") +services_pkg.__path__ = [os.path.join(BACKEND_DIR, "services")] +tag_management_service = _mod("services.tag_management_service") +tag_management_service.TagManagementService = MagicMock() + class _MinIOStorageConfig: def __init__(self, **kwargs): @@ -55,7 +61,7 @@ def __init__(self, **kwargs): nexent_storage_factory.MinIOStorageConfig = _MinIOStorageConfig for mod in (nexent_pkg, nexent_utils, nexent_http_mgr, nexent_storage, - nexent_storage_factory): + nexent_storage_factory, services_pkg, tag_management_service): sys.modules.setdefault(mod.__name__, mod) # Register non-prefixed ``database`` / ``database.client`` stubs so that @@ -568,6 +574,7 @@ def test_list_intersects_before_pagination_and_fetches_only_visible_detail(self) user_id=USER_ID, tenant_id=TENANT_ID, aidp_tenant_id="aidp", + keyword=None, ) mock_detail.assert_called_once_with( aidp_mgmt_app.AIDP_SERVER_URL, @@ -575,6 +582,44 @@ def test_list_intersects_before_pagination_and_fetches_only_visible_detail(self) "kb-2", ) + def test_list_forwards_trimmed_keyword_to_aidp(self): + """A keyword is trimmed before it reaches AIDP.""" + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from types import SimpleNamespace + + with patch.object( + aidp_mgmt_app, + "resolve_current_aidp_access", + return_value=SimpleNamespace(accessible_rows=[]), + ) as mock_snapshot: + response = client.get( + "/aidp-mgmt/knowledge-bases?keyword=%20report%20", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert mock_snapshot.call_args.kwargs["keyword"] == "report" + + def test_list_treats_blank_keyword_as_unfiltered(self): + """A whitespace-only keyword must not narrow the listing.""" + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from types import SimpleNamespace + + with patch.object( + aidp_mgmt_app, + "resolve_current_aidp_access", + return_value=SimpleNamespace(accessible_rows=[]), + ) as mock_snapshot: + response = client.get( + "/aidp-mgmt/knowledge-bases?keyword=%20%20", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert mock_snapshot.call_args.kwargs["keyword"] is None + def test_list_skips_detail_when_catalog_row_has_card_metadata(self): client = _client() from ext_components.aidp.apps import aidp_mgmt_app @@ -750,8 +795,11 @@ def test_list_documents_uses_count_api(self): from ext_components.aidp.apps import aidp_mgmt_app from ext_components.aidp.services import aidp_permission_service + # No channel is resolvable, so the request exercises the completed-files + # fallback (the history path has its own tests below). with patch.object(aidp_permission_service, "require_permission", return_value=MagicMock(permission="READ_ONLY")), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", return_value=[]), \ patch.object(aidp_mgmt_app, "list_aidp_docs_impl", return_value={"value": [{"name": "a"}]}), \ patch.object(aidp_mgmt_app, "count_aidp_docs_impl", return_value=42): @@ -763,6 +811,7 @@ def test_list_documents_uses_count_api(self): body = response.json() assert body["total_count"] == 42 assert body["has_more"] is True + assert body["processing_count"] == 0 def test_list_and_count_requests_run_concurrently(self): client = _client() @@ -783,6 +832,10 @@ def count_docs(*_args, **_kwargs): aidp_permission_service, "require_permission", return_value=MagicMock(permission="READ_ONLY"), + ), patch.object( + aidp_mgmt_app, + "get_cached_aidp_channels", + return_value=[], ), patch.object( aidp_mgmt_app, "list_aidp_docs_impl", @@ -801,6 +854,153 @@ def count_docs(*_args, **_kwargs): assert response.json()["total_count"] == 1 +# --- Remove/download documents ------------------------------------------- + + +class TestAidpDocumentFileOperations: + def test_remove_forwards_only_uuids(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + aidp_result = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "success_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], + "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000002"}], + } + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="EDIT"), + ), patch.object( + aidp_mgmt_app, + "remove_aidp_docs_impl", + return_value=aidp_result, + ) as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={ + "file_uuids": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ] + }, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == aidp_result + assert mock_remove.call_args.args[3] == [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ] + + def test_remove_requires_file_uuids(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + + with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={}, + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + mock_remove.assert_not_called() + + def test_remove_requires_standard_file_uuid(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + + with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={"file_uuids": ["uuid-1"]}, + ) + + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + mock_remove.assert_not_called() + + def test_download_returns_binary_response(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + mock_response = httpx.Response( + 200, + headers={ + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="a.txt"', + "X-File-Size": "5", + }, + content=b"hello", + request=httpx.Request("POST", SERVER_URL), + ) + + async def stream_document(*args): + return mock_response + + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="READ_ONLY"), + ), patch.object( + aidp_mgmt_app, + "stream_aidp_doc_impl", + side_effect=stream_document, + ) as mock_download: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/download", + headers=_bearer(), + json={"file_uuid": "00000000-0000-4000-8000-000000000001"}, + ) + + assert response.status_code == HTTPStatus.OK + assert response.content == b"hello" + assert response.headers["content-type"].startswith("text/plain") + assert response.headers["content-disposition"] == 'attachment; filename="a.txt"' + assert response.headers["x-file-size"] == "5" + assert mock_download.call_args.args == ( + SERVER_URL, + API_KEY, + "kb-1", + "00000000-0000-4000-8000-000000000001", + ) + assert "x-file-name" not in response.headers + + def test_remove_does_not_invalidate_cache_when_no_file_succeeds(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + aidp_result = { + "summary": {"total": 1, "success": 0, "failed": 1}, + "success_list": [], + "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], + } + with patch.object( + aidp_permission_service, + "require_permission", + return_value=MagicMock(permission="EDIT"), + ), patch.object( + aidp_mgmt_app, + "remove_aidp_docs_impl", + return_value=aidp_result, + ), patch.object(aidp_mgmt_app, "invalidate_aidp_kb_detail_cache") as mock_kb_cache, patch.object( + aidp_mgmt_app, "invalidate_aidp_doc_count_cache" + ) as mock_count_cache: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", + headers=_bearer(), + json={"file_uuids": ["00000000-0000-4000-8000-000000000001"]}, + ) + + assert response.status_code == HTTPStatus.OK + mock_kb_cache.assert_not_called() + mock_count_cache.assert_not_called() + # --- Models list (auth only, no per-KB permission) ------------------------ @@ -1178,6 +1378,7 @@ def test_list_docs_falls_back_when_count_api_fails(self): with patch.object(aidp_permission_service, "require_permission", return_value=MagicMock(permission="READ_ONLY")), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", return_value=[]), \ patch.object(aidp_mgmt_app, "list_aidp_docs_impl", return_value={"value": [{"a": 1}, {"a": 2}]}), \ patch.object(aidp_mgmt_app, "count_aidp_docs_impl", @@ -1357,3 +1558,282 @@ def test_update_skips_sync_when_no_name_change(self): assert response.status_code == HTTPStatus.OK # kds_name is None/empty in both result and body -> sync skipped mock_update_perm.assert_not_called() + + +# --------------------------------------------------------------------------- +# Document list - all-status history data source +# --------------------------------------------------------------------------- + + +class TestListDocumentsHistory: + """The document list prefers the all-status history over completed files. + + AIDP only exposes ingested files through ``.../KnowledgeFiles``, so the list + used to stay empty (and the processing state invisible) while an upload was + still being chunked/embedded. These tests pin the new source: channels + + history, in-process pagination, live statuses, and the graceful fallback + that keeps the endpoint working on AIDP builds without those endpoints. + """ + + _CHANNELS = [ + {"fs_id": "fs-1", "src_dir": "/aidp/knowledge/kb-1", "kds_id": "kb-1"} + ] + + @staticmethod + def _read_only(): + return MagicMock(permission="READ_ONLY") + + def test_history_source_report_statuses_and_processing_count(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + history = { + "value": [ + {"file_ino_no": "f-old", "file_name": "old.txt", + "first_upload_time": 1718000000, "status": "COMPLETED"}, + {"file_ino_no": "f-new", "file_name": "new.pdf", + "first_upload_time": 1718000900, "status": "PROCESSING"}, + {"file_ino_no": "f-bad", "file_name": "bad.txt", + "first_upload_time": 1718000800, "status": "FAILED"}, + ] + } + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value=history) as mock_history, \ + patch.object(aidp_mgmt_app, "list_aidp_docs_impl") as mock_completed, \ + patch.object(aidp_mgmt_app, "count_aidp_docs_impl") as mock_count: + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + # Newest upload first, so an accepted file is visible on page 1. + assert [item["file_ino_no"] for item in body["value"]] == [ + "f-new", "f-bad", "f-old", + ] + assert [item["status"] for item in body["value"]] == [ + "PROCESSING", "FAILED", "COMPLETED", + ] + assert body["total_count"] == 3 + assert body["has_more"] is False + assert body["total_reliable"] is True + assert body["processing_count"] == 1 + # The history payload is authoritative: the completed-files listing and + # its Count endpoint must not be hit at all. + mock_history.assert_called_once() + # The call carries the resolved channel plus the KB the path is scoped to. + assert mock_history.call_args.args[2:] == ( + "fs-1", "/aidp/knowledge/kb-1", "kb-1", + ) + mock_completed.assert_not_called() + mock_count.assert_not_called() + + @pytest.mark.parametrize("page,expected_count,expected_has_more", [ + (1, 10, True), + (2, 2, False), + ]) + def test_history_source_paginates_in_process(self, page, expected_count, expected_has_more): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + history = { + "value": [ + {"file_ino_no": f"f-{index}", "file_name": f"{index}.txt", + "first_upload_time": 1718000000 + index, "status": "COMPLETED"} + for index in range(12) + ] + } + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value=history): + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/documents?page={page}&page_size=10", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert len(body["value"]) == expected_count + assert body["total_count"] == 12 + assert body["has_more"] is expected_has_more + assert body["processing_count"] == 0 + + def test_items_without_numeric_timestamp_sort_last(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + history = { + "value": [ + {"file_ino_no": "no-time", "file_name": "iso.txt", + "created_at": "2024-06-10T00:00:00Z", "status": "COMPLETED"}, + {"file_ino_no": "timestamped", "file_name": "ts.txt", + "first_upload_time": 1718000000, "status": "COMPLETED"}, + ] + } + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value=history): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert [item["file_ino_no"] for item in response.json()["value"]] == [ + "timestamped", "no-time", + ] + + def test_history_source_ignores_non_dict_items(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + history = { + "value": [ + "bogus", + {"file_ino_no": "f-1", "file_name": "ok.txt", + "first_upload_time": 1718000000, "status": "PROCESSING"}, + ] + } + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value=history): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert [item["file_ino_no"] for item in body["value"]] == ["f-1"] + assert body["total_count"] == 1 + + def test_history_failure_falls_back_to_completed_listing(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + side_effect=AppException(ErrorCode.AIDP_SERVICE_ERROR, "history down")), \ + patch.object(aidp_mgmt_app, "list_aidp_docs_impl", + return_value={"value": [{"file_name": "done.txt"}]}), \ + patch.object(aidp_mgmt_app, "count_aidp_docs_impl", return_value=1): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["value"] == [{"file_name": "done.txt"}] + assert body["total_count"] == 1 + # Nothing is known to be processing when the fallback runs. + assert body["processing_count"] == 0 + + def test_empty_history_falls_back_to_kb_scoped_listing(self): + """An empty channel directory must not blank a KB that has files.""" + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value={"value": []}), \ + patch.object(aidp_mgmt_app, "list_aidp_docs_impl", + return_value={"value": [{"file_name": "done.txt"}]}), \ + patch.object(aidp_mgmt_app, "count_aidp_docs_impl", return_value=1): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["value"] == [{"file_name": "done.txt"}] + assert body["total_count"] == 1 + + def test_history_source_counts_every_in_progress_stage(self): + """Uploading/extracting keep polling alive just like processing does.""" + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + history = { + "value": [ + {"file_ino_no": "f-up", "file_name": "up.txt", + "first_upload_time": 1718000000, "status": "UPLOADING"}, + {"file_ino_no": "f-ex", "file_name": "ex.pdf", + "first_upload_time": 1718000100, "status": "EXTRACTING"}, + {"file_ino_no": "f-pr", "file_name": "pr.pdf", + "first_upload_time": 1718000200, "status": "PROCESSING"}, + {"file_ino_no": "f-done", "file_name": "done.txt", + "first_upload_time": 1718000300, "status": "COMPLETED"}, + ] + } + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + return_value=self._CHANNELS), \ + patch.object(aidp_mgmt_app, "list_aidp_doc_history_impl", + return_value=history): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["processing_count"] == 3 + assert [item["status"] for item in body["value"]] == [ + "COMPLETED", "PROCESSING", "EXTRACTING", "UPLOADING", + ] + + def test_unexpected_history_error_falls_back_to_completed_listing(self): + """A non-AppException bug in the history path must not break the list.""" + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=self._read_only()), \ + patch.object(aidp_mgmt_app, "get_cached_aidp_channels", + side_effect=TypeError("unexpected")), \ + patch.object(aidp_mgmt_app, "list_aidp_docs_impl", + return_value={"value": [{"file_name": "done.txt"}]}), \ + patch.object(aidp_mgmt_app, "count_aidp_docs_impl", return_value=1): + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/documents", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()["value"] == [{"file_name": "done.txt"}] diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py index e8a6c67906..46decb8328 100644 --- a/test/ext_components/aidp/test_aidp_service.py +++ b/test/ext_components/aidp/test_aidp_service.py @@ -1,8 +1,9 @@ import importlib.util +import logging import os import sys from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -277,6 +278,115 @@ def test_fetches_pages_from_count_and_uses_configured_tenant(self, aidp_service_ "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases?page=2&page_size=100", ] + def test_forwards_url_encoded_keyword_on_every_page(self, aidp_service_module): + """The keyword rides along on every list request, percent-encoded.""" + aidp_service_module.count_aidp_kbs_impl.return_value = 101 + page1 = MagicMock(status_code=200) + page1.raise_for_status.return_value = None + page1.json.return_value = {"value": [{"kds_id": "kb-1"}], "next_link": None} + page2 = MagicMock(status_code=200) + page2.raise_for_status.return_value = None + page2.json.return_value = {"value": [{"kds_id": "kb-1"}], "next_link": None} + mock_client = MagicMock() + mock_client.get.side_effect = [page1, page2] + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + keyword="季度 报告", + ) + + encoded = "%E5%AD%A3%E5%BA%A6%20%E6%8A%A5%E5%91%8A" + requested_urls = [call.args[0] for call in mock_client.get.call_args_list] + assert requested_urls == [ + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases" + f"?page=1&page_size=100&keyword={encoded}", + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases" + f"?page=2&page_size=100&keyword={encoded}", + ] + + def test_omits_keyword_parameter_when_blank(self, aidp_service_module): + """A blank keyword must produce the same URL as an unfiltered call.""" + aidp_service_module.count_aidp_kbs_impl.return_value = 1 + page1 = MagicMock(status_code=200) + page1.raise_for_status.return_value = None + page1.json.return_value = {"value": [{"kds_id": "kb-1"}], "next_link": None} + mock_client = MagicMock() + mock_client.get.return_value = page1 + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + keyword=" ", + ) + + assert mock_client.get.call_args.args[0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases" + "?page=1&page_size=100" + ) + + def test_keyword_ignored_by_upstream_is_applied_locally(self, aidp_service_module): + """A build that ignores ``?keyword=`` must not return the full catalog.""" + aidp_service_module.count_aidp_kbs_impl.return_value = 2 + page1 = MagicMock(status_code=200) + page1.raise_for_status.return_value = None + page1.json.return_value = { + "value": [ + {"kds_id": "kb-faq", "kds_name": "AIDP FAQ"}, + {"kds_id": "kb-java", "kds_name": "Java Migration Notes"}, + ], + "next_link": None, + } + mock_client = MagicMock() + mock_client.get.return_value = page1 + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + result = aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + keyword="faq", + ) + + assert [item["kds_id"] for item in result["value"]] == ["kb-faq"] + assert result["total_count"] == 1 + + def test_upstream_filtered_result_is_left_untouched(self, aidp_service_module): + """Once AIDP narrowed the set, its own matches stay authoritative.""" + aidp_service_module.count_aidp_kbs_impl.return_value = 101 + page1 = MagicMock(status_code=200) + page1.raise_for_status.return_value = None + # A match made on AIDP's own terms (for example tokenized) that a plain + # substring check would miss — it must survive the fallback. + page1.json.return_value = { + "value": [{"kds_id": "kb-1", "kds_name": "Quarterly"}], + "next_link": None, + } + page2 = MagicMock(status_code=200) + page2.raise_for_status.return_value = None + page2.json.return_value = {"value": [], "next_link": None} + mock_client = MagicMock() + mock_client.get.side_effect = [page1, page2] + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + result = aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + keyword="reports", + ) + + assert [item["kds_id"] for item in result["value"]] == ["kb-1"] + assert result["total_count"] == 1 + def test_deduplicates_kds_ids_across_pages(self, aidp_service_module): aidp_service_module.count_aidp_kbs_impl.return_value = 101 page1 = MagicMock(status_code=200) @@ -1091,6 +1201,16 @@ def _setup_mock_client(aidp_service_module, method="get", response=None, side_ef return mock_client +def _setup_mock_async_client(aidp_service_module, response=None, side_effect=None): + """Create and wire an async mock client into the service module manager.""" + mock_client = MagicMock() + mock_client.send = AsyncMock(side_effect=side_effect, return_value=response) + mock_manager = MagicMock() + mock_manager.get_async_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + return mock_client + + def _make_http_error(status_code, method="GET"): """Create an httpx.HTTPStatusError with given status code.""" request = httpx.Request(method, "http://127.0.0.1:30081") @@ -1995,7 +2115,7 @@ def test_invalid_config(self, aidp_service_module, server_url, api_key): def test_success_normalizes_docs(self, aidp_service_module): mock_resp = _make_success_response({ "value": [ - {"name": "doc1", "first_upload_time": 1700000000}, + {"name": "doc1", "file_uuid": "uuid-1", "first_upload_time": 1700000000}, {"name": "doc2", "create_time": 1700100000, "update_time": 1700200000}, ], "total_count": 2, @@ -2012,6 +2132,7 @@ def test_success_normalizes_docs(self, aidp_service_module): assert len(result["value"]) == 2 # Normalization adds created_at / updated_at assert result["value"][0]["created_at"] is not None + assert result["value"][0]["file_uuid"] == "uuid-1" assert result["value"][1]["updated_at"] is not None def test_success_non_list_value_not_normalized(self, aidp_service_module): @@ -2081,6 +2202,147 @@ def test_json_parse_value_error(self, aidp_service_module): assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR +# --------------------------------------------------------------------------- +# remove_aidp_docs_impl / download_aidp_doc_impl tests +# --------------------------------------------------------------------------- +class TestAidpDocumentFileOperations: + def test_remove_sends_uuid_array_and_preserves_partial_result( + self, aidp_service_module + ): + expected = { + "summary": {"total": 2, "success": 1, "failed": 1}, + "success_list": [{"file_uuid": "uuid-1"}], + "failed_list": [{"file_uuid": "uuid-2"}], + } + mock_client = _setup_mock_client( + aidp_service_module, + method="post", + response=_make_success_response(expected), + ) + + result = aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", + "jwt-token", + "kb-1", + ["uuid-1", "uuid-2"], + ) + + assert result == expected + call = mock_client.post.call_args + assert call.args[0].endswith( + "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Remove" + ) + assert call.kwargs["json"] == {"file_uuids": ["uuid-1", "uuid-2"]} + + def test_remove_maps_request_error(self, aidp_service_module): + request = httpx.Request("POST", "http://127.0.0.1:30081") + _setup_mock_client( + aidp_service_module, + method="post", + side_effect=httpx.RequestError("network down", request=request), + ) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + def test_remove_maps_invalid_json(self, aidp_service_module): + mock_response = _make_success_response({}) + mock_response.json.side_effect = ValueError("bad json") + _setup_mock_client(aidp_service_module, method="post", response=mock_response) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + @pytest.mark.parametrize("status_code", [401, 403, 500]) + def test_remove_maps_upstream_http_errors(self, aidp_service_module, status_code): + _setup_mock_client( + aidp_service_module, + method="post", + side_effect=_make_http_error(status_code, "POST"), + ) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.remove_aidp_docs_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] + ) + expected_code = ( + ErrorCode.AIDP_AUTH_ERROR + if status_code in (401, 403) + else ErrorCode.AIDP_SERVICE_ERROR + ) + assert exc_info.value.error_code == expected_code + + @pytest.mark.asyncio + async def test_download_streams_binary_content_and_headers(self, aidp_service_module): + mock_response = httpx.Response( + 200, + headers={ + "Content-Type": "text/plain", + "Content-Disposition": 'attachment; filename="a.txt"', + "X-File-Size": "16", + }, + content=b"downloaded bytes", + request=httpx.Request("POST", "http://127.0.0.1:30081"), + ) + mock_client = _setup_mock_async_client(aidp_service_module, response=mock_response) + + response = await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + chunks = [chunk async for chunk in response.aiter_bytes()] + + assert b"".join(chunks) == b"downloaded bytes" + assert response.headers["Content-Type"] == "text/plain" + assert response.headers["Content-Disposition"] == 'attachment; filename="a.txt"' + assert response.headers["X-File-Size"] == "16" + request = mock_client.build_request.call_args + assert request.args[0] == "POST" + assert request.args[1].endswith( + "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Download" + ) + assert request.kwargs["json"] == {"file_uuid": "uuid-1"} + await response.aclose() + assert mock_response.is_closed + + @pytest.mark.asyncio + async def test_download_maps_request_error(self, aidp_service_module): + request = httpx.Request("POST", "http://127.0.0.1:30081") + _setup_mock_async_client( + aidp_service_module, + side_effect=httpx.RequestError("network down", request=request), + ) + + with pytest.raises(AppException) as exc_info: + await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + @pytest.mark.asyncio + async def test_download_maps_upstream_http_error(self, aidp_service_module): + response = httpx.Response( + 404, + json={"error": "file not found"}, + request=httpx.Request("POST", "http://127.0.0.1:30081"), + ) + _setup_mock_async_client( + aidp_service_module, + response=response, + ) + + with pytest.raises(AppException) as exc_info: + await aidp_service_module.stream_aidp_doc_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + + # --------------------------------------------------------------------------- # list_aidp_models_impl tests # --------------------------------------------------------------------------- @@ -2202,3 +2464,620 @@ def test_json_parse_value_error(self, aidp_service_module): server_url="http://127.0.0.1:30081", api_key="jwt-token" ) assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + +# --------------------------------------------------------------------------- +# select_aidp_channel tests +# --------------------------------------------------------------------------- +class TestSelectAidpChannel: + """Tests for select_aidp_channel (channel -> history addressing helper).""" + + def test_matches_channel_by_explicit_kds_id(self, aidp_service_module): + channels = [ + {"fs_id": "fs-a", "src_dir": "/dir/a", "kds_id": "kb-a"}, + {"fs_id": "fs-b", "src_dir": "/dir/b", "kds_id": "kb-b"}, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-b") == { + "fs_id": "fs-b", + "src_dir": "/dir/b", + } + + def test_matches_channel_by_source_directory(self, aidp_service_module): + channels = [ + {"fs_id": "fs-a", "src_dir": "/dir/a"}, + {"fs_id": "fs-b", "src_dir": "/knowledge/kb-b"}, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-b") == { + "fs_id": "fs-b", + "src_dir": "/knowledge/kb-b", + } + + def test_matches_channel_by_id_list(self, aidp_service_module): + channels = [ + {"fs_id": "fs-a", "src_dir": "/dir/a", "knowledge_base_ids": ["kb-x", "kb-y"]}, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-y")["fs_id"] == "fs-a" + + def test_accepts_alias_field_names(self, aidp_service_module): + channels = [{"fsId": "fs-alias", "srcDir": "/dir/alias"}] + assert aidp_service_module.select_aidp_channel(channels, "kb-1") == { + "fs_id": "fs-alias", + "src_dir": "/dir/alias", + } + + def test_falls_back_to_first_usable_channel(self, aidp_service_module): + """Single-channel deployments are addressed by the only channel there is.""" + channels = [ + {"fs_id": "fs-only", "src_dir": "/dir/only"}, + ] + assert aidp_service_module.select_aidp_channel(channels, "unrelated-kb") == { + "fs_id": "fs-only", + "src_dir": "/dir/only", + } + + def test_reads_nested_addressing_fields(self, aidp_service_module): + """A channel that nests fs_id/src_dir still resolves.""" + channels = [ + { + "id": "channel-2", + "config": {"fs_id": "fs-nested", "src_dir": "/knowledge/kb-nested"}, + }, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-nested") == { + "fs_id": "fs-nested", + "src_dir": "/knowledge/kb-nested", + } + + def test_matches_channel_by_undocumented_field(self, aidp_service_module): + """A channel naming its knowledge base in any field is still matched.""" + channels = [ + {"fs_id": "fs-a", "src_dir": "/dir/a", "channel_name": "kb-named"}, + {"fs_id": "fs-b", "src_dir": "/dir/b", "channel_name": "other"}, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-named") == { + "fs_id": "fs-a", + "src_dir": "/dir/a", + } + + def test_skips_entries_missing_fs_id_or_dir(self, aidp_service_module): + channels = [ + {"fs_id": "", "src_dir": "/dir/empty"}, + {"fs_id": "fs-a", "src_dir": ""}, + {"kds_id": "kb-a"}, + {"fs_id": "fs-ok", "src_dir": "/dir/ok"}, + ] + assert aidp_service_module.select_aidp_channel(channels, "kb-a") == { + "fs_id": "fs-ok", + "src_dir": "/dir/ok", + } + + def test_returns_none_when_nothing_usable(self, aidp_service_module): + assert aidp_service_module.select_aidp_channel([], "kb-1") is None + assert aidp_service_module.select_aidp_channel( + [{"fs_id": "fs-a"}, "not-a-dict"], "kb-1" + ) is None + + +# --------------------------------------------------------------------------- +# list_aidp_channels_impl tests +# --------------------------------------------------------------------------- +class TestListAidpChannelsImpl: + """Tests for list_aidp_channels_impl (GET .../KnowledgeBases/{id}/Channels).""" + + _KB = "kb-1" + + @pytest.mark.parametrize( + "server_url,api_key", + [("", "token"), ("ftp://bad", "token"), ("http://ok", "")], + ) + def test_invalid_config(self, aidp_service_module, server_url, api_key): + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url=server_url, api_key=api_key, kds_id=self._KB + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONFIG_INVALID + + @pytest.mark.parametrize("kds_id", ["", " "]) + def test_missing_knowledge_base_id_raises_without_request( + self, aidp_service_module, kds_id + ): + """The catalog is KB-scoped: a request without a KB id cannot be built.""" + mock_client = _setup_mock_client(aidp_service_module, method="get") + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=kds_id, + ) + + assert exc_info.value.error_code == ErrorCode.AIDP_CONFIG_INVALID + mock_client.get.assert_not_called() + + def test_success_requests_knowledge_base_scoped_path(self, aidp_service_module): + mock_resp = _make_success_response({ + "value": [{"fs_id": "fs-1", "src_dir": "/dir/1"}], + }) + mock_client = _setup_mock_client( + aidp_service_module, method="get", response=mock_resp + ) + + result = aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + + assert result["value"] == [{"fs_id": "fs-1", "src_dir": "/dir/1"}] + call_args = mock_client.get.call_args + assert call_args[0][0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/Channels" + ) + assert call_args.kwargs["headers"]["Authorization"] == "Bearer jwt-token" + + def test_tenant_override_is_forwarded(self, aidp_service_module): + mock_resp = _make_success_response({"value": []}) + mock_client = _setup_mock_client( + aidp_service_module, method="get", response=mock_resp + ) + + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + tenant_id="other", + ) + + assert ( + "/KnowledgeBase/Tenants/other/KnowledgeBases/kb-1/Channels" + in mock_client.get.call_args[0][0] + ) + + def test_non_dict_entries_are_dropped(self, aidp_service_module): + mock_resp = _make_success_response({"value": ["junk", {"fs_id": "fs-1"}]}) + _setup_mock_client(aidp_service_module, method="get", response=mock_resp) + + result = aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + + assert result["value"] == [{"fs_id": "fs-1"}] + + @pytest.mark.parametrize("key", ["data", "result", "records", "items", "list"]) + def test_list_envelopes_are_normalized_to_value( + self, aidp_service_module, key + ): + """A renamed list wrapper must not read as 'no channels upstream'.""" + mock_resp = _make_success_response({key: [{"fs_id": "fs-1", "src_dir": "/dir/1"}]}) + _setup_mock_client(aidp_service_module, method="get", response=mock_resp) + + result = aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + + assert result["value"] == [{"fs_id": "fs-1", "src_dir": "/dir/1"}] + + def test_bare_list_response_is_accepted(self, aidp_service_module): + mock_resp = _make_success_response([{"fs_id": "fs-1", "src_dir": "/dir/1"}]) + _setup_mock_client(aidp_service_module, method="get", response=mock_resp) + + result = aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + + assert result["value"] == [{"fs_id": "fs-1", "src_dir": "/dir/1"}] + + @pytest.mark.parametrize("payload", [{"value": "not-a-list"}, "junk", 123]) + def test_payload_without_any_list_raises(self, aidp_service_module, payload): + mock_resp = _make_success_response(payload) + _setup_mock_client(aidp_service_module, method="get", response=mock_resp) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + @pytest.mark.parametrize("status_code,expected", [ + (401, ErrorCode.AIDP_AUTH_ERROR), + (403, ErrorCode.AIDP_AUTH_ERROR), + (429, ErrorCode.AIDP_RATE_LIMIT), + (500, ErrorCode.AIDP_SERVICE_ERROR), + ]) + def test_http_errors_are_mapped(self, aidp_service_module, status_code, expected): + _setup_mock_client( + aidp_service_module, method="get", + side_effect=_make_http_error(status_code), + ) + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + assert exc_info.value.error_code == expected + + def test_connection_error(self, aidp_service_module): + request = httpx.Request("GET", "http://127.0.0.1:30081") + _setup_mock_client( + aidp_service_module, method="get", + side_effect=httpx.RequestError("network down", request=request), + ) + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + def test_json_parse_value_error(self, aidp_service_module): + mock_resp = _make_success_response({}) + mock_resp.json.side_effect = ValueError("bad json") + _setup_mock_client(aidp_service_module, method="get", response=mock_resp) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_channels_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + +# --------------------------------------------------------------------------- +# list_aidp_doc_history_impl tests +# --------------------------------------------------------------------------- +class TestListAidpDocHistoryImpl: + """Tests for list_aidp_doc_history_impl (KB-scoped KnowledgeFiles/History).""" + + _KB = "kb-1" + + @pytest.mark.parametrize("fs_id,dir_path", [ + ("", "/dir/1"), + ("fs-1", ""), + (" ", "/dir/1"), + ("fs-1", " "), + ]) + def test_missing_address_params_raise_without_request( + self, aidp_service_module, fs_id, dir_path + ): + mock_client = _setup_mock_client(aidp_service_module, method="post") + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id=fs_id, + dir_path=dir_path, + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONFIG_INVALID + mock_client.post.assert_not_called() + + @pytest.mark.parametrize("kds_id", ["", " "]) + def test_missing_knowledge_base_id_raises_without_request( + self, aidp_service_module, kds_id + ): + """The endpoint is KB-scoped: a request without a KB id cannot be built.""" + mock_client = _setup_mock_client(aidp_service_module, method="post") + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=kds_id, + ) + + assert exc_info.value.error_code == ErrorCode.AIDP_CONFIG_INVALID + mock_client.post.assert_not_called() + + def test_success_posts_body_and_normalizes_status(self, aidp_service_module): + mock_resp = _make_success_response({ + "value": [ + { + "file_ino_no": "f-1", + "file_name": "doc.pdf", + "first_upload_time": 1718000000, + "status": "processing", + }, + { + "file_ino_no": "f-2", + "file_name": "done.txt", + "first_upload_time": 1718000100, + "status": " COMPLETED ", + }, + ], + }) + mock_client = _setup_mock_client( + aidp_service_module, method="post", response=mock_resp + ) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/aidp/knowledge/kb-1", + kds_id=self._KB, + ) + + assert [item["status"] for item in result["value"]] == [ + "PROCESSING", "COMPLETED", + ] + # Timestamps are normalized like every other document payload. + assert result["value"][0]["created_at"] is not None + + call_args = mock_client.post.call_args + assert call_args[0][0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/KnowledgeFiles/History" + ) + assert call_args.kwargs["json"] == { + "fs_id": "fs-1", + "dir_path": "/aidp/knowledge/kb-1", + } + assert call_args.kwargs["headers"]["Authorization"] == "Bearer jwt-token" + + def test_tenant_override_is_forwarded(self, aidp_service_module): + mock_resp = _make_success_response({"value": []}) + mock_client = _setup_mock_client( + aidp_service_module, method="post", response=mock_resp + ) + + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + tenant_id="other", + ) + + assert ( + "/KnowledgeBase/Tenants/other/KnowledgeBases/kb-1/KnowledgeFiles/History" + in mock_client.post.call_args[0][0] + ) + + def test_items_without_status_keep_no_status_key(self, aidp_service_module): + mock_resp = _make_success_response({ + "value": [{"file_ino_no": "f-1", "file_name": "doc.txt"}], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert "status" not in result["value"][0] + + def test_blank_status_is_not_reported(self, aidp_service_module): + mock_resp = _make_success_response({ + "value": [{"file_ino_no": "f-1", "status": " "}], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert "status" not in result["value"][0] + + @pytest.mark.parametrize( + "alias", ["file_status", "fileStatus", "file_state", "doc_status"] + ) + def test_status_aliases_are_emitted_under_status( + self, aidp_service_module, alias + ): + """A deployment-specific status field still fills the status column.""" + mock_resp = _make_success_response({ + "value": [ + {"file_ino_no": "f-1", "file_name": "doc.pdf", alias: " processing "}, + ], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert result["value"][0]["status"] == "PROCESSING" + + def test_canonical_status_wins_over_alias(self, aidp_service_module): + mock_resp = _make_success_response({ + "value": [ + { + "file_ino_no": "f-1", + "status": "COMPLETED", + "file_status": "FAILED", + }, + ], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert result["value"][0]["status"] == "COMPLETED" + + def test_non_string_status_values_are_ignored(self, aidp_service_module): + """Numeric state vocabularies must not be mistaken for a file status.""" + mock_resp = _make_success_response({ + "value": [{"file_ino_no": "f-1", "file_state": 4, "status": None}], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert "status" not in result["value"][0] + + def test_unreadable_status_payload_is_reported( + self, aidp_service_module, caplog + ): + """The real field name must be discoverable from the log.""" + mock_resp = _make_success_response({ + "value": [{"file_ino_no": "f-1", "upload_phase": "chunking"}], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + with caplog.at_level(logging.WARNING): + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert "no readable status" in caplog.text + assert "upload_phase" in caplog.text + + @pytest.mark.parametrize( + "key", ["data", "result", "records", "items", "list"] + ) + def test_list_envelopes_are_normalized_to_value(self, aidp_service_module, key): + mock_resp = _make_success_response({ + key: [{"file_ino_no": "f-1", "status": "processing"}], + }) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert [item["status"] for item in result["value"]] == ["PROCESSING"] + + @pytest.mark.parametrize("payload", [{"value": "not-a-list"}, "junk"]) + def test_payload_without_any_list_raises(self, aidp_service_module, payload): + mock_resp = _make_success_response(payload) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + def test_response_without_list_carries_empty_value(self, aidp_service_module): + """An empty directory is a valid answer, not a payload error.""" + mock_resp = _make_success_response({"value": []}) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert result["value"] == [] + + def test_bare_list_response_is_accepted(self, aidp_service_module): + """Some builds answer with the array itself instead of an envelope.""" + mock_resp = _make_success_response( + [{"file_ino_no": "f-1", "status": "completed"}] + ) + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + result = aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + + assert [item["status"] for item in result["value"]] == ["COMPLETED"] + + @pytest.mark.parametrize("status_code,expected", [ + (401, ErrorCode.AIDP_AUTH_ERROR), + (403, ErrorCode.AIDP_AUTH_ERROR), + (429, ErrorCode.AIDP_RATE_LIMIT), + (500, ErrorCode.AIDP_SERVICE_ERROR), + ]) + def test_http_errors_are_mapped(self, aidp_service_module, status_code, expected): + _setup_mock_client( + aidp_service_module, method="post", + side_effect=_make_http_error(status_code, method="POST"), + ) + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + assert exc_info.value.error_code == expected + + def test_connection_error(self, aidp_service_module): + request = httpx.Request("POST", "http://127.0.0.1:30081") + _setup_mock_client( + aidp_service_module, method="post", + side_effect=httpx.RequestError("network down", request=request), + ) + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + + def test_json_parse_value_error(self, aidp_service_module): + mock_resp = _make_success_response({}) + mock_resp.json.side_effect = ValueError("bad json") + _setup_mock_client(aidp_service_module, method="post", response=mock_resp) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_doc_history_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + fs_id="fs-1", + dir_path="/dir/1", + kds_id=self._KB, + ) + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index 38432812c2..0968b2aaf4 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -2715,6 +2715,42 @@ def mock_step_stream(_action_step): assert len(agent.memory.steps) == 2 assert agent.memory.steps[0].error is not None + def test_cmsr_004_terminal_model_error_stops_react_without_memory_append( + self, monkeypatch + ): + """A depleted model budget must not become another recoverable ReAct step.""" + + class FakeAgentError(Exception): + pass + + monkeypatch.setattr(core_agent_module, "AgentError", FakeAgentError) + agent = self._create_canonical_run_agent(monkeypatch) + terminal = core_agent_module.ModelInvocationTerminalError( + SimpleNamespace(value="model_timeout"), + 5, + cause=TimeoutError("provider detail"), + ) + physical_steps = 0 + + def failing_step(_action_step): + nonlocal physical_steps + physical_steps += 1 + if False: + yield None + raise terminal + + agent._step_stream = failing_step + + with pytest.raises(core_agent_module.ModelInvocationTerminalError) as exc_info: + list(agent._run_stream("test task", max_steps=10)) + + assert exc_info.value is terminal + assert physical_steps == 1 + assert agent.step_number == 1 + assert agent.memory.steps == [] + agent._finalize_step.assert_not_called() + agent._collect_step_metrics.assert_not_called() + def test_planning_run_retries_empty_direct_answer_then_verifies_valid_answer(self, monkeypatch): """Planning runs reset state, retry an empty answer, and verify the next answer.""" module = core_agent_module @@ -2881,6 +2917,22 @@ def test_handle_max_steps_reached_model_error_fallback(self): ] assert len(error_calls) >= 1 + def test_cmsr_004_max_steps_propagates_terminal_model_error(self): + agent, module = self._create_agent_for_handle_max_steps_test() + terminal = module.ModelInvocationTerminalError( + SimpleNamespace(value="model_timeout"), + 5, + cause=TimeoutError("provider detail"), + ) + agent.model = MagicMock(side_effect=terminal) + agent._finalize_step = MagicMock() + + with pytest.raises(module.ModelInvocationTerminalError) as exc_info: + agent._handle_max_steps_reached("original task") + + assert exc_info.value is terminal + agent._finalize_step.assert_not_called() + def test_handle_max_steps_reached_empty_content_uses_fallback(self, caplog, monkeypatch): """Empty max-step synthesis returns a visible fallback and records why.""" agent, _module = self._create_agent_for_handle_max_steps_test() diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index 452736a8b9..faa630a364 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -2028,6 +2028,32 @@ def test_agent_run_with_observer_with_exception(nexent_agent_instance, mock_core ) +def test_cmsr_004_terminal_model_error_emits_one_safe_error( + nexent_agent_instance, mock_core_agent +): + nexent_agent_instance.agent = mock_core_agent + terminal_error_type = nexent_agent.ModelInvocationTerminalError + model_error_code = terminal_error_type.safe_message.__globals__["ModelErrorCode"] + terminal = terminal_error_type( + model_error_code.SERVICE_UNAVAILABLE, + 5, + cause=RuntimeError("private provider body"), + ) + mock_core_agent.run.side_effect = terminal + + with pytest.raises(terminal_error_type) as exc_info: + nexent_agent_instance.agent_run_with_observer("test query") + + assert exc_info.value is terminal + mock_core_agent.observer.add_message.assert_called_once_with( + agent_name="test_agent", + process_type=ProcessType.ERROR, + content="The model service is temporarily unavailable. Please try again later.", + error_code="model_service_unavailable", + retryable=False, + ) + + def test_agent_run_with_observer_invalid_agent_type(nexent_agent_instance): """Test agent_run_with_observer raises TypeError when agent is not a CoreAgent.""" nexent_agent_instance.agent = "not_core_agent" @@ -4805,7 +4831,7 @@ def test_non_shared_workspace_pushes_archive( else: grant.assert_not_called() - def test_grant_sandbox_output_access_uses_sandbox_group(self, tmp_path): + def test_grant_sandbox_output_access_grants_parent_traversal(self, tmp_path): workspace = tmp_path / "tenant" / "user" / "run-1" input_dir = workspace / "inputs" output_dir = workspace / "outputs" @@ -4817,12 +4843,16 @@ def test_grant_sandbox_output_access_uses_sandbox_group(self, tmp_path): MagicMock(exit_code=0, output=b""), MagicMock(exit_code=0, output=b""), MagicMock(exit_code=0, output=b""), + MagicMock(exit_code=0, output=b""), + MagicMock(exit_code=0, output=b""), ] NexentAgent._grant_sandbox_output_access(container, workspace) assert container.exec_run.call_args_list == [ call(["id", "-g"]), + call(["chgrp", "1000", str(workspace.parent)], user="0"), + call(["chmod", "g+xs", str(workspace.parent)], user="0"), call(["chgrp", "-R", "1000", str(workspace)], user="0"), call(["chmod", "-R", "g+rwX", str(workspace)], user="0"), call( diff --git a/test/sdk/core/agents/test_subagent_wrapper.py b/test/sdk/core/agents/test_subagent_wrapper.py index 1cc88287e7..ba336f9962 100644 --- a/test/sdk/core/agents/test_subagent_wrapper.py +++ b/test/sdk/core/agents/test_subagent_wrapper.py @@ -7,6 +7,7 @@ import pytest from nexent.core.agents.subagent_wrapper import SubAgentToolWrapper, _default_task_extractor +from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError class InnerAgent: @@ -146,3 +147,23 @@ def test_call_still_balances_observer_events_when_inner_raises(observer: Mock) - start_kwargs = observer.add_subagent_start.call_args.kwargs end_kwargs = observer.add_subagent_end.call_args.kwargs assert start_kwargs["invocation_id"] == end_kwargs["invocation_id"] + + +def test_cmsr_004_managed_subagent_preserves_terminal_model_error(observer: Mock) -> None: + terminal = ModelInvocationTerminalError( + ModelErrorCode.SERVICE_UNAVAILABLE, + 5, + cause=RuntimeError("provider detail"), + ) + wrapper = SubAgentToolWrapper( + Mock(side_effect=terminal), + observer, + agent_id="agent-1", + agent_name="Research", + ) + + with pytest.raises(ModelInvocationTerminalError) as exc_info: + wrapper(task="x") + + assert exc_info.value is terminal + assert observer.add_subagent_end.call_count == 1 diff --git a/test/sdk/core/models/test_model_silent_retry.py b/test/sdk/core/models/test_model_silent_retry.py new file mode 100644 index 0000000000..d69b1b7191 --- /dev/null +++ b/test/sdk/core/models/test_model_silent_retry.py @@ -0,0 +1,37 @@ +import pytest + +from nexent.core.models.retry import ( + DEFAULT_MODEL_RETRY, + ModelErrorCode, + classify_model_error, +) + + +class HttpError(RuntimeError): + def __init__(self, status_code: int, message: str = "provider error"): + super().__init__(message) + self.status_code = status_code + + +def test_cmsr_001_default_budget_is_five_total_attempts(): + assert DEFAULT_MODEL_RETRY.max_attempts == 5 + + +@pytest.mark.parametrize( + ("error", "retryable", "error_code"), + [ + (HttpError(429), True, ModelErrorCode.RATE_LIMIT_EXHAUSTED), + (HttpError(503), True, ModelErrorCode.SERVICE_UNAVAILABLE), + (TimeoutError("read timeout"), True, ModelErrorCode.TIMEOUT), + (ConnectionError("connection reset"), True, ModelErrorCode.CONNECTION_ERROR), + (HttpError(401), False, ModelErrorCode.AUTHENTICATION_ERROR), + (HttpError(404), False, ModelErrorCode.NOT_FOUND), + (HttpError(422), False, ModelErrorCode.INVALID_REQUEST), + (RuntimeError("unclassified provider bug"), False, ModelErrorCode.UNKNOWN_ERROR), + ], +) +def test_cmsr_002_error_classification_is_typed(error, retryable, error_code): + classification = classify_model_error(error) + + assert classification.retryable is retryable + assert classification.error_code is error_code diff --git a/test/sdk/core/models/test_openai_llm.py b/test/sdk/core/models/test_openai_llm.py index be48403337..8aa9cc2473 100644 --- a/test/sdk/core/models/test_openai_llm.py +++ b/test/sdk/core/models/test_openai_llm.py @@ -2,6 +2,7 @@ import types import importlib.util from pathlib import Path +from types import SimpleNamespace # Ensure SDK package is importable by adding sdk/ to sys.path (do not fallback to stubs) sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "sdk")) @@ -391,8 +392,9 @@ def close(self): stream = TimeoutStream(chunks_before_timeout) model.client.chat.completions.create = lambda **kwargs: stream - with pytest.raises(openai_llm_module.httpx.ReadTimeout): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: model([{"role": "user", "content": "secret-prompt"}]) + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.TIMEOUT timeout_records = [ record for record in caplog.records @@ -1011,15 +1013,13 @@ def test_provider_context_overflow_stops_after_two_recovery_dispatches(openai_mo ) with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): - with pytest.raises( - openai_llm_module.ProviderContextOverflowRetryExhausted, - match="persisted after two recovery dispatches", - ): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: openai_model_instance.__call__( messages, context_rebuild=lambda: messages, _overflow_recovery_ordinal=2, ) + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.CONTEXT_OVERFLOW def test_provider_context_overflow_without_rebuild_is_retry_unsafe(openai_model_instance): @@ -1029,11 +1029,9 @@ def test_provider_context_overflow_without_rebuild_is_retry_unsafe(openai_model_ ) with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): - with pytest.raises( - openai_llm_module.ProviderContextOverflowRetryUnsafe, - match="cannot be safely rebuilt", - ): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: openai_model_instance.__call__(messages, context_rebuild=None) + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.CONTEXT_OVERFLOW def test_provider_context_overflow_does_not_recover_unrelated_error(openai_model_instance): @@ -1353,10 +1351,11 @@ def test_call_rejects_reasoning_only_response_and_records_diagnostics( ] with pytest.raises( - openai_llm_module.EmptyModelResponseError, + openai_llm_module.ModelInvocationTerminalError, match="finish_reason=length", - ): + ) as exc_info: openai_model_instance.__call__(messages) + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED diagnostics = openai_model_instance.last_response_diagnostics assert diagnostics["finish_reason"] == "length" @@ -1447,8 +1446,25 @@ def test_init_with_ssl_verify_true(): assert kwargs["timeout"].read == 60.0 -def test_ut_sdk_tlm_035_uses_openai_http_implementation_timeout(): - """Use the Timeout class owned by the HTTP implementation behind OpenAI.""" +def test_cmsr_001_init_disables_hidden_openai_transport_retries(): + captured = {} + + def fake_base_init(self, *args, **kwargs): + captured.update(kwargs) + self.client = SimpleNamespace() + + with patch.object( + openai_llm_module.OpenAIServerModel, + "__init__", + fake_base_init, + ): + ImportedOpenAIModel(observer=MagicMock()) + + assert captured["client_kwargs"]["max_retries"] == 0 + + +def test_ut_sdk_tlm_035_falls_back_to_public_httpx_timeout_for_test_double(): + """Use public httpx when the injected OpenAI client has no HTTP base.""" class SDKTimeout: def __init__(self, *, connect, read, write, pool): @@ -1457,11 +1473,7 @@ def __init__(self, *, connect, read, write, pool): self.write = write self.pool = pool - sdk_httpx = types.SimpleNamespace(Timeout=SDKTimeout) - openai_base_client = types.ModuleType("openai._base_client") - openai_base_client.httpx2 = sdk_httpx - - with patch.dict(sys.modules, {"openai._base_client": openai_base_client}), \ + with patch.object(openai_llm_module.httpx, "Timeout", SDKTimeout), \ patch("openai.DefaultHttpxClient") as mock_httpx_client: ImportedOpenAIModel(observer=MagicMock(), ssl_verify=True) @@ -1472,6 +1484,29 @@ def __init__(self, *, connect, read, write, pool): ) +def test_cmsr_compatible_timeout_uses_default_clients_http_implementation(): + """OpenAI's httpx2 client must receive an httpx2 timeout, not httpx.Timeout.""" + + compatible_timeout = MagicMock() + timeout_type = MagicMock(return_value=compatible_timeout) + http_module = SimpleNamespace(Timeout=timeout_type) + compatible_client_base = type("Client", (), {}) + compatible_client_base.__module__ = "httpx2._client" + default_client = type("DefaultClient", (compatible_client_base,), {}) + + with patch.object(openai_llm_module.importlib, "import_module", return_value=http_module): + result = openai_llm_module._build_compatible_http_timeout( + default_client, + connect=10.0, + read=60.0, + write=30.0, + pool=10.0, + ) + + assert result is compatible_timeout + timeout_type.assert_called_once_with(connect=10.0, read=60.0, write=30.0, pool=10.0) + + # --------------------------------------------------------------------------- # Tests for monitoring and token_tracker integration # --------------------------------------------------------------------------- @@ -1718,10 +1753,11 @@ def test_call_api_returns_string_raises_value_error(openai_model_instance): messages = [{"role": "user", "content": [{"text": "Hello"}]}] with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.retry_config = _retry_model_config() # Mock the client to return a string instead of a stream openai_model_instance.client.chat.completions.create.return_value = "error: rate limit exceeded" - with pytest.raises(ValueError, match="LLM API returned error string: error: rate limit exceeded"): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError, match="LLM API returned error string: error: rate limit exceeded"): openai_model_instance.__call__(messages) @@ -1730,10 +1766,11 @@ def test_call_api_returns_dict_with_error_raises_value_error(openai_model_instan messages = [{"role": "user", "content": [{"text": "Hello"}]}] with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.retry_config = _retry_model_config() # Mock the client to return a dict error response openai_model_instance.client.chat.completions.create.return_value = {"error": "rate limit exceeded"} - with pytest.raises(ValueError, match="LLM API returned error: rate limit exceeded"): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError, match="LLM API returned error: rate limit exceeded"): openai_model_instance.__call__(messages) @@ -1742,10 +1779,11 @@ def test_call_api_returns_dict_with_message_raises_value_error(openai_model_inst messages = [{"role": "user", "content": [{"text": "Hello"}]}] with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.retry_config = _retry_model_config() # Mock the client to return a dict with 'message' field openai_model_instance.client.chat.completions.create.return_value = {"message": "invalid api key"} - with pytest.raises(ValueError, match="LLM API returned error: invalid api key"): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError, match="LLM API returned error: invalid api key"): openai_model_instance.__call__(messages) @@ -1754,10 +1792,11 @@ def test_call_api_returns_plain_dict_raises_value_error(openai_model_instance): messages = [{"role": "user", "content": [{"text": "Hello"}]}] with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.retry_config = _retry_model_config() # Mock the client to return a plain dict openai_model_instance.client.chat.completions.create.return_value = {"status": "fail"} - with pytest.raises(ValueError, match="LLM API returned error:"): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError, match="LLM API returned error:"): openai_model_instance.__call__(messages) @@ -2405,34 +2444,89 @@ def fake_create(stream=True, **kwargs): openai_model_instance.retry_config = _retry_model_config(max_attempts=max_attempts) openai_model_instance.client.chat.completions.create.side_effect = fake_create - with pytest.raises(_StatusErr) as exc_info: + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: openai_model_instance.__call__([{"role": "user", "content": "hello"}]) - assert exc_info.value.status_code == 503 + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.SERVICE_UNAVAILABLE + assert exc_info.value.attempts == max_attempts assert calls["n"] == max_attempts assert openai_model_instance.last_retry_count == max_attempts - 1 -def test_non_retryable_fails_immediately(openai_model_instance): - """A 401 must NOT be retried; the call fails on the first attempt.""" +def test_cmsr_001_default_retry_budget_makes_exactly_five_physical_calls( + openai_model_instance, +): calls = {"n": 0} def fake_create(stream=True, **kwargs): calls["n"] += 1 - raise _StatusErr(401, "Unauthorized") + raise _StatusErr(503, "Service Unavailable") + + openai_model_instance.retry_config = openai_llm_module.ModelRetryConfig( + backoff_base_seconds=0, + max_backoff_seconds=0, + jitter=False, + ) + openai_model_instance.client.chat.completions.create.side_effect = fake_create + + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: + openai_model_instance.__call__([{"role": "user", "content": "hello"}]) + + assert calls["n"] == 5 + assert exc_info.value.attempts == 5 + + +@pytest.mark.parametrize( + ("failure", "expected_code"), + [ + (_StatusErr(401, "Unauthorized"), openai_llm_module.ModelErrorCode.AUTHENTICATION_ERROR), + (_StatusErr(400, "Bad request"), openai_llm_module.ModelErrorCode.INVALID_REQUEST), + (RuntimeError("unclassified provider bug"), openai_llm_module.ModelErrorCode.UNKNOWN_ERROR), + ], +) +def test_non_retryable_fails_immediately(openai_model_instance, failure, expected_code): + """Authentication, request and unknown failures must fail on the first call.""" + calls = {"n": 0} + + def fake_create(stream=True, **kwargs): + calls["n"] += 1 + raise failure openai_model_instance.retry_config = _retry_model_config() openai_model_instance.client.chat.completions.create.side_effect = fake_create - with pytest.raises(_StatusErr) as exc_info: + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: openai_model_instance.__call__([{"role": "user", "content": "hello"}]) - assert exc_info.value.status_code == 401 + assert exc_info.value.error_code is expected_code assert calls["n"] == 1 -def test_reasoning_only_stop_response_retries_once_then_propagates(openai_model_instance): - """A reasoning-only stop response gets one transparent retry.""" +def test_cmsr_002_retry_after_controls_retry_wait(openai_model_instance): + calls = {"n": 0} + rate_limit = _StatusErr(429, "Rate limit") + rate_limit.response = SimpleNamespace(headers={"Retry-After": "3.5"}) + + def fake_create(stream=True, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise rate_limit + return [_make_content_chunk("ok")] + + wait_event = MagicMock() + wait_event.is_set.return_value = False + openai_model_instance.stop_event = wait_event + openai_model_instance.retry_config = _retry_model_config(backoff_base=1.0) + openai_model_instance.client.chat.completions.create.side_effect = fake_create + + result = openai_model_instance.__call__([{"role": "user", "content": "hello"}]) + + assert result is not None + wait_event.wait.assert_called_once_with(3.5) + + +def test_reasoning_only_stop_response_exhausts_shared_attempt_budget(openai_model_instance): + """A reasoning-only stop response uses the configured shared attempt budget.""" calls = {"n": 0} def fake_create(stream=True, **kwargs): @@ -2446,10 +2540,11 @@ def fake_create(stream=True, **kwargs): openai_model_instance.retry_config = _retry_model_config() openai_model_instance.client.chat.completions.create.side_effect = fake_create - with pytest.raises(openai_llm_module.EmptyModelResponseError): + with pytest.raises(openai_llm_module.ModelInvocationTerminalError) as exc_info: openai_model_instance.__call__([{"role": "user", "content": "hello"}]) - assert calls["n"] == 2 + assert exc_info.value.error_code is openai_llm_module.ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED + assert calls["n"] == openai_model_instance.retry_config.max_attempts def test_reasoning_only_stop_response_recovers_on_retry(openai_model_instance): diff --git a/test/sdk/core/utils/test_observer_model_attempts.py b/test/sdk/core/utils/test_observer_model_attempts.py new file mode 100644 index 0000000000..33a1437d79 --- /dev/null +++ b/test/sdk/core/utils/test_observer_model_attempts.py @@ -0,0 +1,66 @@ +import json + +from nexent.core.utils.observer import MessageObserver, ProcessType + + +def _events(observer: MessageObserver) -> list[dict]: + return [json.loads(item) for item in observer.get_cached_message()] + + +def test_cmsr_003_model_attempt_events_stamp_chunks_and_reset_parser_state(): + observer = MessageObserver(lang="en") + + observer.begin_model_attempt("attempt-one", 1) + observer.add_model_reasoning_content("partial reasoning") + observer.add_model_new_token("partial code") + observer.rollback_model_attempt("attempt-one", 1) + + assert not observer.token_buffer + assert not observer.think_buffer + assert observer.current_mode is ProcessType.MODEL_OUTPUT_THINKING + assert observer.in_think_mode is False + + events = _events(observer) + assert events[0] == { + "type": "model_attempt_control", + "content": "", + "phase": "begin", + "attempt_id": "attempt-one", + "attempt": 1, + } + assert events[1]["attempt_id"] == "attempt-one" + assert events[-1]["phase"] == "rollback" + + +def test_cmsr_003_new_attempt_never_inherits_failed_attempt_identity(): + observer = MessageObserver(lang="en") + observer.begin_model_attempt("attempt-one", 1) + observer.rollback_model_attempt("attempt-one", 1) + observer.begin_model_attempt("attempt-two", 2) + observer.add_model_reasoning_content("clean") + observer.commit_model_attempt("attempt-two", 2) + + events = _events(observer) + model_event = next(event for event in events if event["type"] == "model_output_deep_thinking") + assert model_event["attempt_id"] == "attempt-two" + assert events[-1]["phase"] == "commit" + + +def test_cmsr_004_terminal_error_keeps_string_content_and_stable_metadata(): + observer = MessageObserver(lang="en") + observer.add_message( + "agent", + ProcessType.ERROR, + "The model request failed.", + error_code="model_unknown_error", + retryable=False, + ) + + assert _events(observer) == [ + { + "type": "error", + "content": "The model request failed.", + "error_code": "model_unknown_error", + "retryable": False, + } + ] From f78871d754a9e94fa5940310276a8ae0d8aed236 Mon Sep 17 00:00:00 2001 From: Jason Wang <56037774+JasonW404@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:47:33 +0800 Subject: [PATCH 2/5] [fix] enforce explicit CodeAgent termination and silent recovery (#3969) * fix(agent): enforce explicit CodeAgent termination * fix(test): restore CodeAgent CI compatibility --- backend/agents/nl2skill_agent.py | 1 + backend/prompts/nl2agent_en.yaml | 18 +- backend/prompts/nl2agent_zh.yaml | 20 +- .../prompts/skill_creation_complicate_en.yaml | 9 +- .../prompts/skill_creation_complicate_zh.yaml | 9 +- backend/prompts/skill_creation_simple_en.yaml | 7 +- backend/prompts/skill_creation_simple_zh.yaml | 7 +- backend/prompts/utils/prompt_generate_en.yaml | 40 +- backend/prompts/utils/prompt_generate_zh.yaml | 40 +- backend/utils/content_classifier_utils.py | 12 +- backend/utils/context_utils.py | 4 +- doc/docs/en/user-guide/start-chat.md | 2 +- doc/docs/zh/user-guide/start-chat.md | 2 +- .../agentConfig/SkillBuildModal.tsx | 7 + .../adapter/remote-chat-model-adapter.ts | 72 +++ .../tests/nl2SkillAttemptRollback.test.ts | 26 + .../configs/gsm8k_solver_assistant.yaml | 2 +- sdk/nexent/core/agents/agent_model.py | 4 + sdk/nexent/core/agents/core_agent.py | 482 +++++++++++------- sdk/nexent/core/agents/nexent_agent.py | 7 +- sdk/nexent/core/agents/output_protocol.py | 204 ++++++++ sdk/nexent/core/models/openai_llm.py | 22 +- .../core/tools/create_scheduled_task_tool.py | 6 +- test/assets/test_prompt.yaml | 6 +- test/assets/test_sub_prompt.yaml | 6 +- .../backend/services/test_nl2skill_service.py | 7 +- .../utils/test_content_classifier_utils.py | 26 + test/sdk/core/agents/test_agent_model.py | 1 + test/sdk/core/agents/test_core_agent.py | 443 ++++++++-------- .../core/agents/test_core_agent_planning.py | 19 +- .../core/agents/test_guardrail_checkpoints.py | 122 ++++- test/sdk/core/agents/test_nexent_agent.py | 24 + test/sdk/core/agents/test_output_protocol.py | 113 ++++ test/sdk/core/models/test_openai_llm.py | 28 + 34 files changed, 1307 insertions(+), 491 deletions(-) create mode 100644 frontend/tests/nl2SkillAttemptRollback.test.ts create mode 100644 sdk/nexent/core/agents/output_protocol.py create mode 100644 test/sdk/core/agents/test_output_protocol.py diff --git a/backend/agents/nl2skill_agent.py b/backend/agents/nl2skill_agent.py index cbb4d6cecb..3729d01374 100644 --- a/backend/agents/nl2skill_agent.py +++ b/backend/agents/nl2skill_agent.py @@ -19,6 +19,7 @@ def create_nl2skill_agent_config( tools=[], max_steps=5, model_name=model_name, + output_protocol="final_answer_envelope", provide_run_summary=False, instructions=system_prompt, enable_planning=False, diff --git a/backend/prompts/nl2agent_en.yaml b/backend/prompts/nl2agent_en.yaml index 7dadec4be4..c49b606893 100644 --- a/backend/prompts/nl2agent_en.yaml +++ b/backend/prompts/nl2agent_en.yaml @@ -18,7 +18,7 @@ system_prompt: |- - Only when the current input is a `full_generation` `requirement_clarification` submission may you use its answers to continue saving the description. Partial field and resource tasks may use only information explicitly provided for the current task and facts from the verified draft. - The Agent display name, generated variable name, verbs such as "create" or "generate", and common domain knowledge are not confirmed requirements. Never infer the task, users, output, or constraints from them. - This completion rule applies only to initial full generation. Configuration is complete only after the description is saved, resource requirements are installed and bound or explicitly abandoned, every Prompt field is saved, and an `agent_generation_completed` state event is received. - - `full_generation` is the only task subject to the initial full-generation completion restriction: before receiving `agent_generation_completed`, do not output a plain completion statement, simulate execution results, or say "I created it" or "already created"; every turn must advance the full-generation state through a business tool or wrapper. A partial task may output its local result or a Revision Summary when complete, but must not claim that the Agent has completed generation. + - `full_generation` is the only task subject to the initial full-generation completion restriction: before receiving `agent_generation_completed`, do not return a completion statement, simulate execution results, or say "I created it" or "already created"; every turn must advance the full-generation state through a business tool or wrapper. A partial task may return its local result or a Revision Summary through `final_answer(...)` when complete, but must not claim that the Agent has completed generation. ### Intent And Minimal Workflow - First determine the task the user wants to complete in this turn, then use the current `nl2agent_verified_state` to select the smallest executable workflow. Do not label the entire conversation as "generation mode" or "revision mode" before identifying the task. @@ -41,7 +41,7 @@ system_prompt: |- - After a resource task receives a `suggested_resource_installation` action, continue the same resource task; after installation, search again for real installed resources. After receiving an `installed_resource_binding` `continue` action, enter the Resource-Dependent Prompt Generation stage using the newly injected `bound_resources`; do not output a summary directly. When no resource will be bound, skip the empty binding card and enter Prompt generation immediately using the authoritative `bound_resources`. - Resource-Dependent Prompt Generation must atomically regenerate and save `duty_prompt`, `constraint_prompt`, and `few_shots_prompt` in that order. These fields must reflect real bound resources and their declared inputs; do not retain content that references removed resources, omits new capabilities, or invents invocation details. - If an added, replaced, or reconfigured resource changes the Agent's purpose, opening capabilities, or the range of actionable user questions, also regenerate and save the affected `description`, `greeting_message`, or `example_questions`. Keep unaffected fields at their persisted values; each save may include only fields that are already determined to require an update. - - After Resource-Dependent Prompt Generation completes, output the Revision Summary, naming the resource and Prompt fields actually updated. Only when the user explicitly requests resource removal, direct them to the Tools and Skills section of the form on the right. + - After Resource-Dependent Prompt Generation completes, return the Revision Summary through `final_answer(...)`, naming the resource and Prompt fields actually updated. Only when the user explicitly requests resource removal, direct them to the Tools and Skills section of the form on the right. - Conversational removal is unsupported. For removal, tell the user to use the Tools and Skills section of the form on the right. For replacement, the new resource may be added first, but tell the user to remove the old resource in that form. - Except for empty-name initialization, `name`, `display_name`, model settings, publication status, version state, and any other field outside the six generated fields are not editable through NL2Agent. Direct the user to the corresponding form on the right without calling a save or resource tool. @@ -61,8 +61,8 @@ system_prompt: |- 2. During full generation, the latest successful tool result has `updated_fields` equal to `["duty_prompt"]`: generate and save only `constraint_prompt`. 3. During full generation, the latest successful tool result has `updated_fields` equal to `["constraint_prompt"]`: generate and save only `few_shots_prompt`. 4. During full generation, the latest successful tool result has `updated_fields` equal to `["few_shots_prompt"]`: generate and save only `greeting_message` and `example_questions` together. - 5. During full generation, the latest successful tool result has `updated_fields` equal to `["greeting_message", "example_questions"]` and contains an `agent_generation_completed` state event: output only the plain text required by Completion Summary. - - After a partial task saves fields or completes a resource operation, output its Revision Summary or local result directly when there is no pending card action. Never generate or save another field merely because the partial task updated `duty_prompt`, `constraint_prompt`, or any other field. + 5. During full generation, the latest successful tool result has `updated_fields` equal to `["greeting_message", "example_questions"]` and contains an `agent_generation_completed` state event: return only the Completion Summary through `final_answer(...)` in the single `...` action. + - After a partial task saves fields or completes a resource operation, return its Revision Summary or local result through `final_answer(...)` when there is no pending card action. Never generate or save another field merely because the partial task updated `duty_prompt`, `constraint_prompt`, or any other field. - Never mention, generate, or save fields from a later branch. If a save fails, correct and retry the current branch once only. ### Variable Name Initialization @@ -85,7 +85,7 @@ system_prompt: |- 6. After a `suggested_resource_installation` action, preserve its `installed` and `skipped` results unchanged. If `installed` is empty, treat that action as the user's explicit choice to continue without any suggested Tool or Skill: do not search for alternatives and do not call `{{ wrapper_name }}` with an empty resource list; generate and save only `duty_prompt` as the next atomic action. Otherwise, search `{{ installed_tool_name }}` again with the same requirements and trust only newly returned real `tool_id`/`skill_id` values. If requirements remain uncovered, place every installed and skipped candidate ref in `exclude_refs` while searching alternatives. When no alternative exists, use a clarification card requiring the user to revise, explicitly abandon, or end; never claim an unbound capability is available. 7. After installed search succeeds, choose the smallest candidate set covering strong matches and keep the total at or below {{ max_results }}. Already-bound Tools remain normal candidates: retain their search scores and include them when selected by the same coverage rules, so the binding card can show their current configuration for confirmation or revision. When the selected set is non-empty, pass unchanged candidates to `{{ recommend_tool_name }}`, decode its result, then pass that unchanged dictionary and the same `agent_id` to `{{ wrapper_name }}` with subtype `installed_resource_binding`. When the selected set is empty, skip both calls and generate and save only `duty_prompt`. 8. After an `installed_resource_binding` action with `continue` or `retry_generation`, use only the newly injected `bound_resources` database facts and follow the Atomic Action Contract strictly, executing only one Prompt branch per model response. - 9. After the final Prompt batch succeeds and `agent_generation_completed` is received, output the plain-text completion summary directly. Do not call another tool or wrapper. For tool errors, use only `code` and `retryable`; retry at most once. + 9. After the final Prompt batch succeeds and `agent_generation_completed` is received, call `final_answer(completion_summary)` inside one literal `...` block. Do not call another business tool or wrapper. For tool errors, use only `code` and `retryable`; retry at most once. ### Clarification Schema Each question contains a stable `question_id`, `question_type` (`single_choice`, `multiple_choice`, or `text`), concise `title`, and `required`. Choice questions include `options` and set `allow_other=True` and `other_input_expanded=True`. Text questions set both fields to `False` because their primary input is already open text. @@ -200,10 +200,10 @@ system_prompt: |- If a Prompt save fails, correct and retry that batch once. On a second failure, stop without a completion summary; successfully saved fields remain unchanged. ### Revision Summary - After a revision save succeeds, or after a revision resource card is confirmed with no Prompt fields selected for synchronization, output one or two concise plain-text paragraphs. Start with "Updated:" and name only the fields or resources actually changed; state that all other configuration remains unchanged. Do not use ``, a wrapper, a Markdown table, or another card. Even if a revision save emits `agent_generation_completed`, never use the first-generation Completion Summary or claim that a new Agent was generated. + After a revision save succeeds, or after a revision resource card is confirmed with no Prompt fields selected for synchronization, compose one or two concise paragraphs, then call `final_answer(revision_summary)` inside one literal `...` block. Start with "Updated:" and name only the fields or resources actually changed; state that all other configuration remains unchanged. Do not call a business tool or wrapper and do not output a Markdown table or another card. Even if a revision save emits `agent_generation_completed`, never use the first-generation Completion Summary or claim that a new Agent was generated. ### Completion Summary - Only after initial full generation receives `agent_generation_completed`, output the following three concise paragraphs. If the confirmed requirements contain a post-generation scheduling intent, append a fourth paragraph. Do not use ``, a wrapper, a Markdown table, or an interactive card. Only the fourth paragraph may use the specified Markdown link below: + Only after initial full generation receives `agent_generation_completed`, compose the following three concise paragraphs and return them with `final_answer(completion_summary)` inside one literal `...` block. If the confirmed requirements contain a post-generation scheduling intent, append a fourth paragraph. Do not call a business tool or wrapper and do not output a Markdown table or interactive card. Only the fourth paragraph may use the specified Markdown link below: 1. State clearly that the new Agent has been generated successfully. 2. Start with "New Agent summary:" and summarize its responsibilities, core capabilities, expected result, and any explicitly abandoned scope from confirmed requirements and real bound resources. Never show raw Prompt content or claim unbound capabilities. @@ -211,8 +211,8 @@ system_prompt: |- 4. Only for a post-generation scheduling intent, state clearly that this workflow has not created a scheduled task and that it must be created after Agent generation from a conversation with the new Agent. Briefly restate any confirmed time or recurrence information, then direct the user to "open [Scheduled tasks](/agent-tasks), select 'Create in chat,' choose the new Agent, and submit the scheduling request." Never claim that the scheduled task already exists. ### Tool And Termination Rules - - Except for the Completion Summary, Revision Summary, and revision boundary guidance, use simple valid Python inside literal `` and `` tags. - - Except for those plain-text outputs, call one business tool per action with keyword arguments, assign its result, and print it exactly once. + - Use simple valid Python inside exactly one literal `` and `` pair for every response that completes the run; Completion Summary, Revision Summary, and revision boundary guidance must call `final_answer(...)` there. + - For non-terminal actions, call one business tool per action with keyword arguments, assign its result, and print it exactly once. - Use only defined values and exact parameter names; do not use `if`, `for`, or repeated identical calls. - Wait for each real tool result before the next action. - A wrapper call is the final business action of an interactive-card run. After printing it, call no other tool. The runtime emits the structured payload and stops. diff --git a/backend/prompts/nl2agent_zh.yaml b/backend/prompts/nl2agent_zh.yaml index d33a669d29..8384e967e0 100644 --- a/backend/prompts/nl2agent_zh.yaml +++ b/backend/prompts/nl2agent_zh.yaml @@ -18,7 +18,7 @@ system_prompt: |- - 只有在 `full_generation` 的当前输入是 `requirement_clarification` 提交 action 时,才可以使用其中的回答继续保存描述。局部字段或资源任务只能使用当前任务明确提供的信息和权威草稿信息。 - 智能体显示名称、生成后的变量名、“生成”“创建”等动词以及常识性的领域能力都不算已确认需求,不得据此自行补全任务、使用者、输出或约束。 - 以下完成标准仅适用于首次完整生成。只有描述已保存、资源需求已安装并绑定或明确放弃、全部 Prompt 字段已保存,并收到 `agent_generation_completed` 状态事件,才算完成配置。 - - `full_generation` 才受“首次完整生成”的完成约束:在收到 `agent_generation_completed` 前,不得输出普通完成说明、模拟执行结果或“已为您生成”“已经创建”等表述;每轮必须通过业务 Tool 或 wrapper 推进完整生成状态。局部任务完成后可以输出局部结果或“已更新”总结,但不得声称 Agent 已完成生成。 + - `full_generation` 才受“首次完整生成”的完成约束:在收到 `agent_generation_completed` 前,不得输出普通完成说明、模拟执行结果或“已为您生成”“已经创建”等表述;每轮必须通过业务 Tool 或 wrapper 推进完整生成状态。局部任务完成后可以通过 `final_answer(...)` 返回局部结果或“已更新”总结,但不得声称 Agent 已完成生成。 ### 任务意图与最小流程 - 先判断用户本轮要完成的任务,再结合当前 `nl2agent_verified_state` 选择最小可执行流程。不要先按“生成模式”或“修订模式”给整轮对话贴标签。 @@ -41,7 +41,7 @@ system_prompt: |- - 资源任务收到 `suggested_resource_installation` action 后,继续当前资源任务;安装完成后必须重新搜索真实的已安装资源。收到 `installed_resource_binding` 的 `continue` action 后,必须使用新注入的 `bound_resources` 进入“资源依赖 Prompt 生成”阶段,不能直接输出总结。没有任何资源需要绑定时,跳过空绑定卡,并基于权威 `bound_resources` 直接进入 Prompt 生成。 - “资源依赖 Prompt 生成”按原子动作依次重新生成并保存 `duty_prompt`、`constraint_prompt` 和 `few_shots_prompt`。这三个字段必须反映真实绑定资源及其已声明输入;不得沿用会引用旧资源、遗漏新能力或编造调用方式的旧内容。 - 如果新增、替换或重新配置的资源改变了 Agent 的职责介绍、开场能力或用户可执行的问题范围,还必须重新生成并保存受影响的 `description`、`greeting_message` 或 `example_questions`。未受影响的字段保持数据库原值;一次保存只能包含当前已确定要更新的字段。 - - 资源依赖 Prompt 生成完成后,输出“修订总结”,说明资源和实际更新的 Prompt 字段。只有用户明确要求移除资源时,才引导其在右侧表单的工具与技能区域操作。 + - 资源依赖 Prompt 生成完成后,通过 `final_answer(...)` 返回“修订总结”,说明资源和实际更新的 Prompt 字段。只有用户明确要求移除资源时,才引导其在右侧表单的工具与技能区域操作。 - 不支持通过对话移除资源。用户要求移除时,引导其在右侧表单的工具与技能区域操作。替换资源时可以先新增资源,但必须提示用户在该表单中移除旧资源。 - 除空变量名初始化外,`name`、`display_name`、模型设置、发布状态、版本状态以及六个生成字段之外的其他字段都不能通过 NL2Agent 修改。引导用户在右侧对应表单操作,不得调用保存或资源 Tool。 @@ -61,9 +61,9 @@ system_prompt: |- 2. 在完整生成或资源依赖 Prompt 生成中,最新成功工具结果的 `updated_fields` 是 `["duty_prompt"]`:只生成并保存 `constraint_prompt`。 3. 在完整生成或资源依赖 Prompt 生成中,最新成功工具结果的 `updated_fields` 是 `["constraint_prompt"]`:只生成并保存 `few_shots_prompt`。 4. 在完整生成中,最新成功工具结果的 `updated_fields` 是 `["few_shots_prompt"]`:只生成并同时保存 `greeting_message` 和 `example_questions`。 - 5. 在资源依赖 Prompt 生成中,最新成功工具结果的 `updated_fields` 是 `["few_shots_prompt"]`:只保存已确定受资源变化影响的 `description`、`greeting_message` 或 `example_questions`;没有受影响字段时,直接输出“修订总结”。 - 6. 在完整生成中,最新成功工具结果的 `updated_fields` 是 `["greeting_message", "example_questions"]`,并且包含 `agent_generation_completed` 状态事件:只输出“完成总结”规定的普通文本。 - - 局部字段或对话优化任务保存成功后,如果没有待处理的当前卡片 action,直接输出对应的“修订总结”或局部结果。资源任务必须完成资源依赖 Prompt 生成后才能输出“修订总结”。不得因为某个局部字段或对话优化任务更新了 `duty_prompt`、`constraint_prompt` 或其他字段,就生成或保存其他字段。 + 5. 在资源依赖 Prompt 生成中,最新成功工具结果的 `updated_fields` 是 `["few_shots_prompt"]`:只保存已确定受资源变化影响的 `description`、`greeting_message` 或 `example_questions`;没有受影响字段时,通过唯一 `...` 动作中的 `final_answer(...)` 返回“修订总结”。 + 6. 在完整生成中,最新成功工具结果的 `updated_fields` 是 `["greeting_message", "example_questions"]`,并且包含 `agent_generation_completed` 状态事件:通过唯一 `...` 动作中的 `final_answer(...)` 返回“完成总结”。 + - 局部字段或对话优化任务保存成功后,如果没有待处理的当前卡片 action,通过唯一 `...` 动作中的 `final_answer(...)` 返回对应的“修订总结”或局部结果。资源任务必须完成资源依赖 Prompt 生成后才能返回“修订总结”。不得因为某个局部字段或对话优化任务更新了 `duty_prompt`、`constraint_prompt` 或其他字段,就生成或保存其他字段。 - 不得在任一分支中提及、生成或保存后续分支的字段。保存失败时只能修正并重试当前分支一次。 ### 变量名初始化 @@ -86,7 +86,7 @@ system_prompt: |- 6. 收到 `suggested_resource_installation` action 后,原样保留 `installed` 与 `skipped` 结果。若 `installed` 为空,视为用户明确选择不安装任何建议的 Tool 或 Skill:不得继续搜索替代资源,也不得使用空资源列表调用 `{{ wrapper_name }}`;下一个原子动作只生成并保存 `duty_prompt`。否则,使用相同 requirements 重新调用 `{{ installed_tool_name }}`,只相信新返回的真实 `tool_id`/`skill_id`;仍未覆盖时把全部已安装和已跳过 candidate refs 放入 `exclude_refs` 搜索替代资源。没有替代资源时使用澄清卡要求用户明确修改需求、放弃需求或结束,禁止声称未绑定的能力可用。 7. 已安装搜索成功后选择覆盖强匹配需求的最小候选集,总数不超过 {{ max_results }}。已绑定 Tool 仍是普通候选:保留搜索分数,并在相同覆盖规则选中它时继续推荐,让绑定卡展示当前配置供用户确认或修改。选中集合非空时,把未改写的候选传给 `{{ recommend_tool_name }}`,解码其结果,再将该字典和同一 `agent_id` 原样传给 `{{ wrapper_name }}` 的 `installed_resource_binding` subtype;选中集合为空时跳过这两个调用,只生成并保存 `duty_prompt`。 8. 收到 `installed_resource_binding` 的 `continue` 或 `retry_generation` action 后,只使用新注入的 `bound_resources` 数据库事实,并严格按“原子动作输出契约”每次只执行一个 Prompt 分支。 - 9. 最后一批 Prompt 保存成功并收到 `agent_generation_completed` 后,直接输出普通文本完成总结,不再调用任何 Tool 或 wrapper。Tool 出错时只依据 `code` 和 `retryable`,最多重试一次。 + 9. 最后一批 Prompt 保存成功并收到 `agent_generation_completed` 后,在唯一一对字面量 `...` 中调用 `final_answer(completion_summary)`。不再调用任何业务 Tool 或 wrapper。Tool 出错时只依据 `code` 和 `retryable`,最多重试一次。 ### 澄清问题 Schema 每个问题包含稳定的 `question_id`、`question_type`(`single_choice`、`multiple_choice` 或 `text`)、简洁 `title` 和 `required`。选择题包含 `options`,并设置 `allow_other=True`、`other_input_expanded=True`。文本题的主输入已经是开放文本,因此两项都设为 `False`。 @@ -201,10 +201,10 @@ system_prompt: |- Prompt 保存失败时只修正并重试该批一次;第二次失败后停止且不输出完成总结,已成功保存的字段保持不变。 ### 修订总结 - 修订字段保存成功后,或修订资源卡已确认且没有选择同步 Prompt 字段时,输出一到两段简洁普通文本。以“已更新:”开头,只说明实际修改的字段或资源,并说明其他配置保持不变。不得使用 ``、wrapper、Markdown 表格或其他卡片。即使修订保存触发 `agent_generation_completed`,也不得使用首次生成的“完成总结”或声称新智能体已完成生成。 + 修订字段保存成功后,或修订资源卡已确认且没有选择同步 Prompt 字段时,组织一到两段简洁文本,并在唯一一对字面量 `...` 中调用 `final_answer(revision_summary)`。以“已更新:”开头,只说明实际修改的字段或资源,并说明其他配置保持不变。不得调用业务 Tool、wrapper,也不得输出 Markdown 表格或其他卡片。即使修订保存触发 `agent_generation_completed`,也不得使用首次生成的“完成总结”或声称新智能体已完成生成。 ### 完成总结 - 只有首次完整生成收到 `agent_generation_completed` 后,才输出以下三段简洁文本;如果确认需求包含生成后定时意图,再追加第四段。不得使用 ``、wrapper、Markdown 表格或交互卡,第四段只能使用下方指定的 Markdown 链接: + 只有首次完整生成收到 `agent_generation_completed` 后,才组织以下三段简洁文本,并在唯一一对字面量 `...` 中调用 `final_answer(completion_summary)`;如果确认需求包含生成后定时意图,再追加第四段。不得调用业务 Tool、wrapper,也不得输出 Markdown 表格或交互卡,第四段只能使用下方指定的 Markdown 链接: 1. 明确说明“新智能体已完成生成”。 2. 以“新智能体总结:”开头,根据已确认需求和真实绑定资源概括职责、核心能力、预期结果,以及已明确放弃的范围(如有);不得展示 Prompt 原文或声称拥有未绑定能力。 @@ -212,8 +212,8 @@ system_prompt: |- 4. 仅当存在生成后定时意图时,明确说明本次尚未创建定时任务,需要在 Agent 生成后通过新 Agent 对话创建。简洁复述用户已确认的时间或周期信息(如有),并提供“前往[定时任务](/agent-tasks),点击‘通过会话创建’,选择新智能体并提交定时执行请求”的引导;不得声称定时任务已经创建。 ### Tool 与终止规则 - - 除“完成总结”“修订总结”和修订边界说明外,使用简单有效的 Python,并放在字面量 `` 和 `` 标签中。 - - 除上述普通文本输出外,每个动作只使用关键字参数调用一个业务 Tool,将结果赋值并且只打印一次。 + - 每个结束当前 run 的响应都必须使用简单有效的 Python,并放在唯一一对字面量 `` 和 `` 标签中;“完成总结”“修订总结”和修订边界说明必须在其中调用 `final_answer(...)`。 + - 非终止动作每次只使用关键字参数调用一个业务 Tool,将结果赋值并且只打印一次。 - 只使用已定义的值和准确参数名,不使用 `if`、`for` 或相同参数的重复调用。 - 等待每次真实工具结果后再进行下一步。 - wrapper 调用是交互卡 run 的最后一个业务动作。打印结果后不得继续调用 Tool;运行时会发送结构化 payload 并终止本轮。 diff --git a/backend/prompts/skill_creation_complicate_en.yaml b/backend/prompts/skill_creation_complicate_en.yaml index 05eb90ab95..ef105c2c03 100644 --- a/backend/prompts/skill_creation_complicate_en.yaml +++ b/backend/prompts/skill_creation_complicate_en.yaml @@ -3,7 +3,8 @@ system_prompt: |- ## Multi-turn conversation - - If essential information is missing, ask one concise clarification question and do not emit XML control blocks in that turn. + - Every response must start with `` on its own line and end with `` on its own line, with no content outside that envelope. + - If essential information is missing, ask one concise clarification question inside `` and do not emit ``, ``, or `` in that turn. - Use both the conversation history and the current skill snapshot when refining a skill. {% if target_files %} - This turn is a targeted file modification. Modify only these files: {{ target_files | join(', ') }}. @@ -15,7 +16,7 @@ system_prompt: |- - Emit blocks in the order ``, zero or more ``, then ``. - Put every XML control tag on a standalone line and do not wrap control blocks in Markdown code fences. - Never quote or explain XML control tags in clarification, reasoning, or summary text; emit them only as real standalone structure. - - Start structured output directly with `` without a Markdown code fence or language marker. + - After ``, start structured output directly with `` without a Markdown code fence or language marker. {% endif %} A skill consists of multiple files, including: core description file (SKILL.md), example documents, script code, and more. @@ -60,6 +61,7 @@ system_prompt: |- ### Single-File Scenario (SKILL.md Only) + --- name: your-skill-name @@ -77,9 +79,11 @@ system_prompt: |- Your friendly message to the user, such as skill created, feature highlights, etc. + ### Multi-File Scenario (SKILL.md + Other Files) + --- name: your-skill-name @@ -105,6 +109,7 @@ system_prompt: |- Your friendly message to the user, such as skill created, feature highlights, etc. + ### File Reference Declaration Rules (Important) diff --git a/backend/prompts/skill_creation_complicate_zh.yaml b/backend/prompts/skill_creation_complicate_zh.yaml index 3bd971ce83..36bafb30d1 100644 --- a/backend/prompts/skill_creation_complicate_zh.yaml +++ b/backend/prompts/skill_creation_complicate_zh.yaml @@ -3,7 +3,8 @@ system_prompt: |- ## 多轮对话规则 - - 如果需求缺少关键信息,先提出一个简洁的澄清问题;该轮不要输出 XML 控制块。 + - 每次响应都必须以独占一行的 `` 开始,并以独占一行的 `` 结束;外层之外不得有任何内容。 + - 如果需求缺少关键信息,在 `` 内提出一个简洁的澄清问题;该轮不要输出 ``、`` 或 ``。 - 修改技能时同时参考对话历史和当前技能快照。 {% if target_files %} - 本轮是定向文件修改。只能修改这些文件:{{ target_files | join(', ') }}。 @@ -15,7 +16,7 @@ system_prompt: |- - 输出顺序固定为 ``、零个或多个 ``、``。 - 所有 XML 控制标签必须独占一行,控制块外不要包裹 Markdown 代码围栏。 - 不要在澄清、思考或总结文本中引用或解释 XML 控制标签;它们只能作为真实结构独占一行输出。 - - 输出结构时直接从 `` 开始,不要添加 Markdown 代码围栏或语言标识。 + - 输出结构时在 `` 后直接输出 ``,不要添加 Markdown 代码围栏或语言标识。 {% endif %} 技能由多个文件组成,包括:核心描述文件(SKILL.md)、示例文档、脚本代码等。 @@ -60,6 +61,7 @@ system_prompt: |- ### 单文件场景(仅需要 SKILL.md) + --- name: your-skill-name @@ -77,9 +79,11 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 + ### 多文件场景(需要 SKILL.md + 其他文件) + --- name: your-skill-name @@ -109,6 +113,7 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 + ### 文件引用声明规则(重要) diff --git a/backend/prompts/skill_creation_simple_en.yaml b/backend/prompts/skill_creation_simple_en.yaml index d9949faf63..1abe0bf595 100644 --- a/backend/prompts/skill_creation_simple_en.yaml +++ b/backend/prompts/skill_creation_simple_en.yaml @@ -3,7 +3,8 @@ system_prompt: |- ## Multi-turn conversation - - If essential information is missing, ask one concise clarification question and do not emit XML control blocks in that turn. + - Every response must start with `` on its own line and end with `` on its own line, with no content outside that envelope. + - If essential information is missing, ask one concise clarification question inside `` and do not emit ``, ``, or `` in that turn. - Use both the conversation history and the current skill snapshot when refining a skill. {% if target_files %} - This turn is a targeted file modification. Modify only these files: {{ target_files | join(', ') }}. @@ -14,7 +15,7 @@ system_prompt: |- - When generating or modifying a skill, output the complete latest snapshot rather than a partial patch. - Put every XML control tag on a standalone line and do not wrap control blocks in Markdown code fences. - Never quote or explain XML control tags in clarification, reasoning, or summary text; emit them only as real standalone structure. - - Start structured output directly with `` without a Markdown code fence or language marker. + - After ``, start structured output directly with `` without a Markdown code fence or language marker. - Once a `` block starts, never end the response or switch to `` before emitting `` on its own line. - Before finishing, verify that the output contains exactly one `` and one matching ``, with `` before ``. {% endif %} @@ -56,6 +57,7 @@ system_prompt: |- ### Format Example + --- name: your-skill-name @@ -73,6 +75,7 @@ system_prompt: |- Your friendly message to the user, such as skill created, feature highlights, etc. + ## Writing Descriptions (Key Point) diff --git a/backend/prompts/skill_creation_simple_zh.yaml b/backend/prompts/skill_creation_simple_zh.yaml index c08534f1f0..fed1fe0aea 100644 --- a/backend/prompts/skill_creation_simple_zh.yaml +++ b/backend/prompts/skill_creation_simple_zh.yaml @@ -3,7 +3,8 @@ system_prompt: |- ## 多轮对话规则 - - 如果需求缺少关键信息,先提出一个简洁的澄清问题;该轮不要输出 XML 控制块。 + - 每次响应都必须以独占一行的 `` 开始,并以独占一行的 `` 结束;外层之外不得有任何内容。 + - 如果需求缺少关键信息,在 `` 内提出一个简洁的澄清问题;该轮不要输出 ``、`` 或 ``。 - 修改技能时同时参考对话历史和当前技能快照。 {% if target_files %} - 本轮是定向文件修改。只能修改这些文件:{{ target_files | join(', ') }}。 @@ -14,7 +15,7 @@ system_prompt: |- - 一旦生成或修改技能,必须输出最新的完整快照,不要只输出局部补丁。 - 所有 XML 控制标签必须独占一行,控制块外不要包裹 Markdown 代码围栏。 - 不要在澄清、思考或总结文本中引用或解释 XML 控制标签;它们只能作为真实结构独占一行输出。 - - 输出结构时直接从 `` 开始,不要添加 Markdown 代码围栏或语言标识。 + - 输出结构时在 `` 后直接输出 ``,不要添加 Markdown 代码围栏或语言标识。 - `` 块一旦开始,就不得在输出 `` 前结束响应或切换到 ``;`` 必须独占一行。 - 输出结束前执行结构自检:必须恰好包含一个 `` 和一个与之配对的 ``,且 `` 必须位于 `` 之前。 {% endif %} @@ -56,6 +57,7 @@ system_prompt: |- ### 格式示例 + --- name: your-skill-name @@ -73,6 +75,7 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 + ## 编写描述(关键) diff --git a/backend/prompts/utils/prompt_generate_en.yaml b/backend/prompts/utils/prompt_generate_en.yaml index 4d62bfe19e..1396142c63 100644 --- a/backend/prompts/utils/prompt_generate_en.yaml +++ b/backend/prompts/utils/prompt_generate_en.yaml @@ -62,7 +62,7 @@ FEW_SHOTS_SYSTEM_PROMPT: |- - To distinguish between code execution and displaying user code, use 'code' for executing code and 'code' for displaying code - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code. - After thinking, when you believe you can answer the user's question, you can generate a final answer directly to the user without generating code and stop the loop. + After thinking, when you can answer the user, call `final_answer(...)` inside the single `...` action block. Never return a bare-text final answer. ### Python Code Specifications 1. If it is considered to be code that needs to be executed, use 'code'. If the code does not need to be executed for display only, use 'code', where language_type can be python, java, javascript, etc.; @@ -96,7 +96,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: The Oriental Pearl TV Tower is located in Lujiazui, Pudong New Area, Shanghai, China, with a height of 468 meters... Think: I have obtained the relevant information, now I will generate the final answer. - The Oriental Pearl TV Tower is located in Lujiazui, Pudong New Area, Shanghai, China... + + final_answer("The Oriental Pearl TV Tower is located in Lujiazui, Pudong New Area, Shanghai, China...") + --- @@ -111,7 +113,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: Trip plan completed: High-speed train G2, departs 8:00, arrives Beijing South Station at 11:30; Hotel near Wangfujing; Recommended attractions: Tiananmen, Forbidden City, Great Wall... Think: I have obtained the travel planning, now I will generate the final answer. - Tomorrow's trip planning from Shanghai to Beijing, including transportation, accommodation, attractions, etc. + + final_answer("Tomorrow's trip planning from Shanghai to Beijing, including transportation, accommodation, attractions, etc.") + --- @@ -134,14 +138,18 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: Based on weather data analysis, today is suitable for outdoor activities. Temperature is moderate (25°C), humidity is normal (60%), sunny weather is perfect for outdoor sports and tourism... Think: I have obtained weather data and analysis results, now I will generate the final answer. - Based on weather data analysis, today's weather is suitable, temperature is moderate, humidity is normal, suitable for outdoor activities. + + final_answer("Based on weather data analysis, today's weather is suitable, temperature is moderate, humidity is normal, suitable for outdoor activities.") + --- Task 4: "Explain what artificial intelligence is" Think: I can answer this question directly without calling any tools or assistants. - Artificial Intelligence (AI) is a branch of computer science dedicated to creating systems that can perform tasks that typically require human intelligence... + + final_answer("Artificial Intelligence (AI) is a branch of computer science dedicated to creating systems that can perform tasks that typically require human intelligence...") + --- @@ -163,7 +171,8 @@ FEW_SHOTS_SYSTEM_PROMPT: |- Think: I have obtained the Python quick sort code, now I will generate the final answer. - The Python quick sort code is as follows: + + final_answer("""The Python quick sort code is as follows: def quick_sort(arr): if len(arr) <= 1: @@ -173,7 +182,8 @@ FEW_SHOTS_SYSTEM_PROMPT: |- middle = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quick_sort(left) + middle + quick_sort(right) - + """) + --- @@ -194,7 +204,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: [search result 1..., search result 2...] Think: I have both search results. Now I will synthesize the answer. - Regarding Python design patterns...; regarding Java design patterns... + + final_answer("Regarding Python design patterns...; regarding Java design patterns...") + --- @@ -215,7 +227,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: {"security": "Found 1 SQL injection vulnerability...", "style": "Function names should use snake_case..."} Think: I have both the security and style analysis results. Now I will merge them into one report. - Security: SQL injection vulnerability found... Style: rename functions to snake_case... + + final_answer("Security: SQL injection vulnerability found... Style: rename functions to snake_case...") + --- @@ -238,7 +252,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: [search results..., analysis report...] Think: I have both the search results and the analysis report. Now I will combine them. - AI trends 2024: ... Sales quarterly analysis: ... + + final_answer("AI trends 2024: ... Sales quarterly analysis: ...") + --- @@ -253,7 +269,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # After tool execution, the system provides the result in subsequent context: Found relevant appointment information... Think: I have the appointment information. Now I will generate the final answer. - Based on the retrieval results... + + final_answer("Based on the retrieval results...") + --- diff --git a/backend/prompts/utils/prompt_generate_zh.yaml b/backend/prompts/utils/prompt_generate_zh.yaml index 8be897a82f..a97eba8e07 100644 --- a/backend/prompts/utils/prompt_generate_zh.yaml +++ b/backend/prompts/utils/prompt_generate_zh.yaml @@ -61,7 +61,7 @@ FEW_SHOTS_SYSTEM_PROMPT: |- - 考虑到代码执行与展示用户代码的区别,使用'代码'表达运行代码,使用'代码'表达展示代码 - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。 - 在思考结束后,当Agent认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 + 在思考结束后,当Agent认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。 ### python代码规范 1. 如果认为是需要执行的代码,使用'代码'格式;如果是不需要执行仅用于展示的代码,使用'代码'格式,其中语言类型例如python、java、javascript等; @@ -95,7 +95,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:东方明珠广播电视塔位于中国上海市浦东新区陆家嘴,塔高468米,是中国著名的地标建筑之一... 思考:我已经获得了有关信息,现在我将生成最终回答。 - 东方明珠广播电视塔位于中国上海市浦东新区陆家嘴... + + final_answer("东方明珠广播电视塔位于中国上海市浦东新区陆家嘴...") + --- @@ -110,7 +112,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:行程规划已完成,包括:高铁G2,8:00出发,11:30到达北京南站;酒店预订于王府井附近;景点推荐:天安门、故宫、长城... 思考:我已经获得了出行规划,现在我将生成最终回答。 - 明天从上海出发去北京的行程规划,包括交通、住宿、景点等。 + + final_answer("明天从上海出发去北京的行程规划,包括交通、住宿、景点等。") + --- @@ -133,14 +137,18 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:根据天气数据分析,今天天气适宜外出活动,温度适中(25℃),湿度正常(60%),晴天适合户外运动和旅游... 思考:我已经获得了天气数据和分析结果,现在我将生成最终回答。 - 根据天气数据分析,今天天气适宜,温度适中,湿度正常,适合户外活动。 + + final_answer("根据天气数据分析,今天天气适宜,温度适中,湿度正常,适合户外活动。") + --- 任务4:"解释什么是人工智能" 思考:这个问题我可以直接回答,不需要调用任何工具或助手。 - 人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统... + + final_answer("人工智能(AI)是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统...") + --- @@ -160,7 +168,8 @@ FEW_SHOTS_SYSTEM_PROMPT: |- 思考:我已经获得了快速排序的python代码,现在我将生成最终回答。 - 快速排序的python代码如下: + + final_answer("""快速排序的python代码如下: def quick_sort(arr): if len(arr) <= 1: @@ -170,7 +179,8 @@ FEW_SHOTS_SYSTEM_PROMPT: |- middle = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quick_sort(left) + middle + quick_sort(right) - + """) + --- @@ -191,7 +201,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:[搜索结果1..., 搜索结果2...] 思考:已获得两个搜索结果,现在整合回答。 - Python设计模式方面...,Java设计模式方面... + + final_answer("Python设计模式方面...,Java设计模式方面...") + --- @@ -212,7 +224,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:{"security": "发现1个SQL注入漏洞...", "style": "函数名应使用snake_case..."} 思考:已获得安全分析和风格检查结果,现在整合两份报告。 - 安全方面发现SQL注入漏洞...,风格方面建议函数名改为snake_case... + + final_answer("安全方面发现SQL注入漏洞...,风格方面建议函数名改为snake_case...") + --- @@ -235,7 +249,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:[搜索结果..., 数据分析报告...] 思考:已获得搜索结果和分析报告,整合回答。 - 2024年AI发展趋势...,销售数据季度趋势分析... + + final_answer("2024年AI发展趋势...,销售数据季度趋势分析...") + --- @@ -250,7 +266,9 @@ FEW_SHOTS_SYSTEM_PROMPT: |- # 工具执行后,系统在后续上下文中提供结果:找到相关出诊信息... 思考:已获得出诊信息,现在我将生成最终回答。 - 根据检索结果... + + final_answer("根据检索结果...") + --- diff --git a/backend/utils/content_classifier_utils.py b/backend/utils/content_classifier_utils.py index 7907655a5c..92d0a0a9de 100644 --- a/backend/utils/content_classifier_utils.py +++ b/backend/utils/content_classifier_utils.py @@ -31,6 +31,8 @@ def __init__(self): self._origin_type: Optional[str] = None self._state_before_file = "others" self._known_tags = { + "", + "", "", "", "", @@ -103,7 +105,11 @@ def _process_tag_start(self, final: bool = False) -> Optional[List[Dict[str, Any content_after_tag = self.buffer[gt_pos + 1:] if not content_after_tag and not final: return None - if content_after_tag and not content_after_tag.startswith(("\n", "\r\n")): + if ( + content_after_tag + and not content_after_tag.startswith(("\n", "\r\n")) + and matched not in {"", ""} + ): return self._emit_potential_tag_start() results.extend(self._handle_matched_tag(gt_pos, potential_tag, matched)) elif len(potential_tag) > self.MAX_TAG_LENGTH: @@ -258,6 +264,10 @@ def _create_event(self, content: str) -> Dict[str, Any]: def _handle_tag(self, tag: str) -> Optional[Dict[str, Any]]: """Handle matched tag and update state.""" + if tag in {"", ""}: + self.saw_control_tag = True + return None + if tag == "": self.saw_control_tag = True self.state = "skill_body" diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index 26cfdcdb68..840d902bff 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -169,7 +169,7 @@ def _build_execution_flow_text( lines.append(" - 如果自验证提示存在错误、证据不足、参数不完整或结果不可靠,必须优先修正、补充证据、重新调用工具,或清晰说明无法完成的部分。") lines.append(" - 最终回答只有在自验证通过后才会展示给用户;如果系统返回 Verification feedback,请根据该反馈继续修正,不要忽略。") lines.append("") - lines.append("在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。") + lines.append("在思考结束后,当你认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。") lines.append("") lines.append("生成最终回答时,你需要遵循以下规范:") lines.append("1. Markdown格式要求:") @@ -241,7 +241,7 @@ def _build_execution_flow_text( lines.append(" - If verification reports errors, insufficient evidence, incomplete parameters, or unreliable results, you must repair the issue, gather more evidence, call tools again, or clearly state what cannot be completed.") lines.append(" - The final answer is shown to the user only after verification passes. If the system returns Verification feedback, continue revising based on that feedback.") lines.append("") - lines.append("After thinking, when you believe you can answer the user's question, you can generate a final answer directly to the user without generating code and stop the loop.") + lines.append("After thinking, when you can answer the user, call `final_answer(...)` inside the single `...` action block. Never return a bare-text final answer.") lines.append("") lines.append("When generating the final answer, you need to follow these specifications:") lines.append("1. **Markdown Format Requirements**:") diff --git a/doc/docs/en/user-guide/start-chat.md b/doc/docs/en/user-guide/start-chat.md index 7f9aefa650..fa562b73ad 100644 --- a/doc/docs/en/user-guide/start-chat.md +++ b/doc/docs/en/user-guide/start-chat.md @@ -206,7 +206,7 @@ Nexent agents are implemented using the CodeAgent from [smolagents](https://gith ![ReAct loop](./assets/start-chat/ReAct.png) -The loop repeats the reasoning process in the front end until the model determines that it can generate the final answer directly or the maximum number of steps is reached. The final answer is output in Markdown format and supports headings, lists, tables, code blocks, and links. When retrieval tools are used, citation markers such as `[[letter+number]]` must be added after the relevant content to support traceability. +In the front end, the reasoning loop repeats until the model explicitly submits a final answer through the Agent Runtime's `final_answer(...)` action or the maximum number of steps is reached. The final answer is output in Markdown format and supports headings, lists, tables, code blocks, and links. When retrieval tools are used, citation markers such as `[[letter+number]]` must be added after the relevant content to support traceability. ### 3. View Code and Tool Calls diff --git a/doc/docs/zh/user-guide/start-chat.md b/doc/docs/zh/user-guide/start-chat.md index 2e3eae992e..06de0755fb 100644 --- a/doc/docs/zh/user-guide/start-chat.md +++ b/doc/docs/zh/user-guide/start-chat.md @@ -208,7 +208,7 @@ Nexent 智能体基于 [smolagents](https://github.com/huggingface/smolagents) ![ReAct循环](./assets/start-chat/ReAct.png) -循环逻辑在前端是Reasoning重复直到模型判断可以直接生成最终答案,或达到最大步骤数。最终答案以 Markdown 格式输出,支持标题、列表、表格、代码块和链接;若使用了检索工具,还需在对应内容后添加引用标记 `[[字母+数字]]`,以支持溯源。 +循环逻辑在前端表现为 Reasoning 重复,直到模型通过 Agent Runtime 的 `final_answer(...)` 动作显式提交最终答案,或达到最大步骤数。最终答案以 Markdown 格式输出,支持标题、列表、表格、代码块和链接;若使用了检索工具,还需在对应内容后添加引用标记 `[[字母+数字]]`,以支持溯源。 ### 3. 查看代码和工具调用过程 diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index 89c9e75a8e..b02b13fd08 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx @@ -682,6 +682,13 @@ export default function SkillBuildModal({ if (event.type === "agent_new_run" || event.type === "step_count") { setIsStreaming(true); } + if ( + event.type === "model_attempt_control" && + event.phase === "rollback" + ) { + rollbackDraftStream(); + return; + } if (event.type === "skill_body" || event.type === "file_content") { beginDraftStream(); setIsStreaming(true); diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index fcc13ce29d..f394ec5a08 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -1701,6 +1701,14 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const parentReasoning = createReasoningAccumulator(contentParts); const nl2SkillFilePartIndices = new Map(); let nl2SkillSummaryPartIndex: number | null = null; + type Nl2SkillAttemptCheckpoint = { + files: Map; + summary: { index: number; part: any } | null; + }; + const nl2SkillAttemptCheckpoints = new Map< + string, + Nl2SkillAttemptCheckpoint + >(); const classifyNl2SkillFile = ( path: string ): Pick => { @@ -1892,6 +1900,66 @@ export const remoteChatModelAdapter: ChatModelAdapter = { } } }; + const beginNl2SkillAttempt = (attemptId: string) => { + if (!isNl2Skill) return; + const files = new Map(); + for (const [path, index] of nl2SkillFilePartIndices) { + const part = contentParts[index]; + files.set(path, { + index, + part: { + ...part, + data: + part?.data && typeof part.data === "object" + ? { ...part.data } + : part?.data, + }, + }); + } + const summary = + nl2SkillSummaryPartIndex === null + ? null + : { + index: nl2SkillSummaryPartIndex, + part: { ...contentParts[nl2SkillSummaryPartIndex] }, + }; + nl2SkillAttemptCheckpoints.set(attemptId, { files, summary }); + }; + const resolveNl2SkillAttempt = ( + attemptId: string, + phase: "rollback" | "commit" + ) => { + if (!isNl2Skill) return; + const checkpoint = nl2SkillAttemptCheckpoints.get(attemptId); + nl2SkillAttemptCheckpoints.delete(attemptId); + if (phase !== "rollback" || !checkpoint) return; + + const createdIndices = new Set(); + for (const [path, index] of nl2SkillFilePartIndices) { + if (!checkpoint.files.has(path)) createdIndices.add(index); + } + if ( + checkpoint.summary === null && + nl2SkillSummaryPartIndex !== null + ) { + createdIndices.add(nl2SkillSummaryPartIndex); + } + for (const index of [...createdIndices].sort((a, b) => b - a)) { + removeContentPart(index); + } + + nl2SkillFilePartIndices.clear(); + for (const [path, snapshot] of checkpoint.files) { + contentParts[snapshot.index] = snapshot.part; + nl2SkillFilePartIndices.set(path, snapshot.index); + } + if (checkpoint.summary) { + contentParts[checkpoint.summary.index] = checkpoint.summary.part; + nl2SkillSummaryPartIndex = checkpoint.summary.index; + } else { + nl2SkillSummaryPartIndex = null; + } + }; const handleModelAttemptControl = (chunk: SseChunk): boolean => { if ( chunk.type !== "model_attempt_control" || @@ -1904,11 +1972,15 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (!top) { if (chunk.phase === "begin") { parentReasoning.beginAttempt(chunk.attempt_id); + beginNl2SkillAttempt(chunk.attempt_id); } else if (chunk.phase === "rollback") { + resolveNl2SkillAttempt(chunk.attempt_id, "rollback"); parentReasoning.rollbackAttempt(chunk.attempt_id); } else { + resolveNl2SkillAttempt(chunk.attempt_id, "commit"); parentReasoning.commitAttempt(chunk.attempt_id); } + if (isNl2Skill) custom?.onNl2SkillEvent?.(chunk); return true; } diff --git a/frontend/tests/nl2SkillAttemptRollback.test.ts b/frontend/tests/nl2SkillAttemptRollback.test.ts new file mode 100644 index 0000000000..6a84bb99fc --- /dev/null +++ b/frontend/tests/nl2SkillAttemptRollback.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const read = (path: string) => readFile(new URL(path, import.meta.url), "utf8"); + +test("NL2Skill rolls rejected model attempts out of both chat and draft UI", async () => { + const [adapter, modal] = await Promise.all([ + read("../app/[locale]/newchat/adapter/remote-chat-model-adapter.ts"), + read("../app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx"), + ]); + + assert.match(adapter, /beginNl2SkillAttempt\(chunk\.attempt_id\)/); + assert.match( + adapter, + /resolveNl2SkillAttempt\(chunk\.attempt_id, "rollback"\)/ + ); + assert.match(adapter, /custom\?\.onNl2SkillEvent\?\.\(chunk\)/); + assert.match(modal, /event\.type === "model_attempt_control"/); + assert.match(modal, /event\.phase === "rollback"/); + assert.match(modal, /rollbackDraftStream\(\)/); + assert.doesNotMatch( + modal, + /event\.type === "model_attempt_control"[\s\S]{0,160}message\.error/ + ); +}); diff --git a/sdk/benchmark/generic/configs/gsm8k_solver_assistant.yaml b/sdk/benchmark/generic/configs/gsm8k_solver_assistant.yaml index 0dddd3e9fb..6674c39f78 100644 --- a/sdk/benchmark/generic/configs/gsm8k_solver_assistant.yaml +++ b/sdk/benchmark/generic/configs/gsm8k_solver_assistant.yaml @@ -139,7 +139,7 @@ prompt_template: - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。 - **重要**:代码执行后,系统会返回 "Observation:" 标记的内容(这是真实的执行结果)。请基于这些真实结果继续下一步思考,**不要在代码执行前自行编造观察结果**。 - 在思考结束后,当Agent认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 + 在思考结束后,当Agent认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。 ### python代码规范 1. 如果认为是需要执行的代码,使用'代码'格式;如果是不需要执行仅用于展示的代码,使用'代码'格式,其中语言类型例如python、java、javascript等; diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index 3566941f50..f55c436a6b 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -256,6 +256,10 @@ class AgentConfig(BaseModel): ge=1, ) model_name: str = Field(description="Model alias from ModelConfig") + output_protocol: Literal["code_action", "final_answer_envelope"] = Field( + description="Closed model-output protocol used by the Agent runtime", + default="code_action", + ) provide_run_summary: Optional[bool] = Field( description="Whether to provide run summary to upper-level Agent", default=False ) diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 086be61be2..95c3af49e0 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -1,5 +1,5 @@ -import json import ast +import json import logging import os import re @@ -25,7 +25,7 @@ from ...monitor import get_monitoring_manager -from ..model_errors import ModelInvocationTerminalError +from ..model_errors import ModelErrorCode, ModelInvocationTerminalError from ..utils.observer import MessageObserver, ProcessType from jinja2 import Template, StrictUndefined @@ -41,6 +41,17 @@ render_guardrail_refusal, render_tool_input_refusal, ) +from .output_protocol import ( + ExecutableAction, + ExplicitFinalAnswer, + ModelOutputProtocolError, + ProtocolErrorReason, + RuntimeFinalAnswer, + classify_model_output, + has_meaningful_visible_content, + unicode_category_summary, +) +from .context.budget import message_role from ..utils.token_estimation import msg_token_count from .plan_repo import PlanRepo from ..human_interaction.contracts import AttemptSuspended, RecoveryRequired, RunTerminated, StepSteered @@ -210,87 +221,6 @@ def convert_code_format(text): return text -class FinalAnswerError(Exception): - """Raised when agent output directly.""" - pass - - -class InvalidActionFormatError(AgentExecutionError): - """Raised when model output resembles an action but is not executable.""" - - -_ACTION_RECORD_LINE_RE = re.compile( - r"(?im)^\s*(?:[-*#>]\s*)*(?:step\s+\d+\s*:|called\s+tool\b|observation\s*:|tool_calls?\s*:)" -) -_ACTION_JSON_KEYS = frozenset({"action", "tool_call", "tool_calls", "arguments"}) -_ACTION_INTENT_RE = re.compile( - r"(?is)(?:^|\n)\s*(?:(?:思考|分析|thoughts?|analysis)\s*[::].{0,800}" - r"|(?:我(?:将|需要|先)|接下来|下一步|i\s+(?:will|need\s+to|should)\b|next\b).{0,240})" - r"(?:调用|使用|检索|搜索|call|use|search|invoke)" -) -_EXPLICIT_FINAL_ANSWER_RE = re.compile( - r"(?is)(?:^|\n)\s*(?:最终回答|final\s+answer)\s*[::]\s*\S" -) - - -def _looks_like_invalid_action_output(text: Any) -> bool: - """Return whether non-executable output appears to be an action protocol record.""" - if not isinstance(text, str): - return False - stripped = text.strip() - if not stripped: - return False - if _ACTION_RECORD_LINE_RE.search(stripped): - return True - if "" in stripped or "" in stripped or "```" in stripped: - return True - if stripped.startswith("```") and stripped.endswith("```"): - first_newline = stripped.find("\n") - if first_newline != -1: - stripped = stripped[first_newline + 1:-3].strip() - if stripped.startswith(("{", "[")): - try: - payload = json.loads(stripped) - except (TypeError, ValueError): - return False - records = payload if isinstance(payload, list) else [payload] - return any( - isinstance(record, dict) and bool(_ACTION_JSON_KEYS.intersection(record)) - for record in records - ) - return False - - -def _looks_like_incomplete_action_output( - text: Any, - available_tool_names: Any = (), - finish_reason: Optional[str] = None, -) -> bool: - """Identify a truncated or unfinished action that must not become a final answer. - - Providers omit the matched stop sequence from their response. A model may - therefore return only a preamble such as "思考:我将调用 - knowledge_base_search" before the executable ```` block. Treating - that preamble as a final answer ends the loop after one model call. - """ - if not isinstance(text, str) or not text.strip(): - return False - if finish_reason == "length": - return True - if _looks_like_invalid_action_output(text): - return True - if _EXPLICIT_FINAL_ANSWER_RE.search(text): - return False - - normalized = text.casefold() - mentioned_tool = any( - str(tool_name).casefold() in normalized - for tool_name in available_tool_names or () - if tool_name - ) - return mentioned_tool and bool(_ACTION_INTENT_RE.search(text)) - - class ToolInputBlockedError(AgentExecutionError): """Raised by the guardrail tool-input wrap when a call is blocked. @@ -327,7 +257,7 @@ def _screened_tool_forward(engine, tool_name, controller, logger, original_forwa if action != "pass": controller.emit(decision.verification_result, message=decision.message) if action in ("block", "terminate"): - # Stash the refusal; _step_stream raises FinalAnswerError from it (no retry loop). + # Stash the refusal; _step_stream raises a trusted runtime final from it. refusal = render_tool_input_refusal(decision, tool_name) controller.pending_tool_block_refusal = refusal raise ToolInputBlockedError(refusal, logger) @@ -507,6 +437,10 @@ def __init__( self.conversation_id = kwargs.pop("conversation_id", None) self.user_id = kwargs.pop("user_id", None) self.workspace_path = kwargs.pop("workspace_path", None) + self.output_protocol = kwargs.pop("output_protocol", "code_action") + if self.output_protocol not in ("code_action", "final_answer_envelope"): + raise ValueError(f"Unsupported output protocol: {self.output_protocol}") + self._consecutive_protocol_errors = 0 self.human_interaction = None context_runtime = kwargs.pop("context_runtime", None) @@ -741,16 +675,25 @@ def _finalize_failed_verification_candidate( ) action_step.is_final_answer = True action_step.action_output = controlled_answer + self._record_output_protocol( + "runtime_final_answer", + final_answer_source="final_verifier_controlled_failure", + ) return True, controlled_answer - def _log_model_call_parameters(self, input_messages: List[ChatMessage], stop_sequences: List[str], additional_args: Dict[str, Any]) -> None: + def _log_model_call_parameters( + self, + input_messages: List[ChatMessage], + stop_sequences: Optional[List[str]], + additional_args: Dict[str, Any], + ) -> None: """ Log model call parameters with content truncation for readability. Args: input_messages: List of chat messages being sent to the model - stop_sequences: Stop sequences for the model + stop_sequences: Optional stop sequences for the model additional_args: Additional arguments passed to the model """ try: @@ -817,6 +760,99 @@ def _provider_overflow_recovery_safe(self) -> bool: for step in self.memory.steps[self._history_step_count:] ) + def _record_output_protocol( + self, + classification: str, + *, + reason: str = "", + final_answer_source: str = "", + ) -> None: + """Attach content-free output-protocol diagnostics to the active trace.""" + + consecutive_errors = getattr(self, "_consecutive_protocol_errors", 0) + repair_ordinal = consecutive_errors if 0 < consecutive_errors <= 2 else 0 + attributes = { + "agent.output_protocol": getattr(self, "output_protocol", "code_action"), + "agent.model_output_classification": classification, + "agent.protocol_error_reason": reason, + "agent.consecutive_protocol_errors": consecutive_errors, + "agent.protocol_repair_ordinal": repair_ordinal, + "agent.final_answer_source": final_answer_source, + } + monitoring_manager = get_monitoring_manager() + monitoring_manager.set_span_attributes(**attributes) + monitoring_manager.add_span_event("agent.output_protocol", attributes) + + def _controlled_protocol_failure(self) -> str: + if str(getattr(self, "lang", "en")).lower().startswith("zh"): + return "模型连续未遵循 Agent 输出协议,本次运行已安全终止。请重试或更换模型。" + return ( + "The model repeatedly failed to follow the Agent output protocol, " + "so this run was stopped safely. Please retry or use another model." + ) + + def _resolve_deferred_model_attempt( + self, + message: ChatMessage | None, + *, + accepted: bool, + ) -> None: + """Commit or roll back one successfully streamed model attempt.""" + + if message is None or not getattr(message, "model_attempt_commit_deferred", False): + return + attempt_id = getattr(message, "model_attempt_id", None) + attempt_number = getattr(message, "model_attempt_number", None) + if not isinstance(attempt_id, str) or not isinstance(attempt_number, int): + return + method_name = "commit_model_attempt" if accepted else "rollback_model_attempt" + resolve = getattr(self.observer, method_name, None) + if callable(resolve): + resolve(attempt_id, attempt_number) + message.model_attempt_commit_deferred = False + + def _append_protocol_repair_context(self, protocol_error: ModelOutputProtocolError) -> None: + """Add a safe model-only correction without persisting rejected output.""" + + repair_messages = getattr(self, "_protocol_repair_messages", None) + if repair_messages is None: + repair_messages = [] + self._protocol_repair_messages = repair_messages + repair_messages.append( + ChatMessage( + role=MessageRole.USER, + content=[{ + "type": "text", + "text": protocol_error.repair_instruction, + }], + ) + ) + + def _ensure_open_model_turn(self, input_messages: list[Any]) -> list[Any]: + """End the request with an explicit continuation turn when history ends in assistant.""" + + if not input_messages or message_role(input_messages[-1]) != "assistant": + return input_messages + if getattr(self, "output_protocol", "code_action") == "final_answer_envelope": + instruction = ( + "Continue the current task from the read-only completed-action record above. " + "Do not repeat any completed action. Return the next response using the required " + "Agent protocol; when complete, return exactly one envelope." + ) + else: + instruction = ( + "Continue the current task from the read-only completed-action record above. " + "Do not repeat any completed action. Return exactly one next executable action " + "using the required Agent protocol; call final_answer(...) when the task is complete." + ) + return [ + *input_messages, + ChatMessage( + role=MessageRole.USER, + content=[{"type": "text", "text": instruction}], + ), + ] + def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: """ Perform one step in the ReAct framework: the agent thinks, acts, and observes the result. @@ -826,9 +862,6 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: if hitl is not None and memory_step.model_output is not None: model_output = memory_step.model_output else: - self.observer.add_message( - self.agent_name, ProcessType.STEP_COUNT, self.step_number) - final_context = self.context_runtime.prepare_step( model=self.model, memory=self.memory, @@ -850,12 +883,25 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: self._last_uncompressed_est = msg_token_count(input_messages, chars_per_token) # Add new step in logs memory_step.model_input_messages = input_messages - stop_sequences = ["Observation:", "Calling tools:"] + # Closed output protocols must receive the complete generation. + # Legacy CodeAgent stop strings can match a reasoning model's first + # tokens; providers then strip the match and return an apparently + # successful empty stream (finish_reason=stop). Strict classification + # below already rejects fabricated observations and protocol prefixes. + stop_sequences: list[str] | None = None # Prepare additional arguments additional_args: dict[str, Any] = {} if self._use_structured_outputs_internally: additional_args["response_format"] = CODEAGENT_RESPONSE_FORMAT + if getattr(self.model, "supports_deferred_attempt_commit", False) is True: + additional_args["_defer_attempt_commit"] = True + + repair_messages = getattr(self, "_protocol_repair_messages", []) + if repair_messages: + input_messages = [*input_messages, *repair_messages] + input_messages = self._ensure_open_model_turn(input_messages) + memory_step.model_input_messages = input_messages # Log model call parameters before execution self._log_model_call_parameters(input_messages, stop_sequences, additional_args) @@ -871,11 +917,9 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: ) if decision.effective_action == "terminate": self._append_verification_feedback(memory_step, decision.verification_result) - # Pre-built refusal as the final answer; FinalAnswerError ends the run (no retry loop). - memory_step.model_output = render_guardrail_refusal( - decision, input_messages - ) - raise FinalAnswerError() + refusal = render_guardrail_refusal(decision, input_messages) + memory_step.model_output = refusal + raise RuntimeFinalAnswer(refusal, "guardrail_input") if decision.effective_action == "mask" and decision.masked_messages is not None: input_messages = decision.masked_messages self._append_verification_feedback(memory_step, decision.verification_result) @@ -908,10 +952,13 @@ def rebuild_after_provider_overflow(): model_output = chat_message.content memory_step.token_usage = chat_message.token_usage memory_step.model_output = model_output - - self.logger.log_markdown( - content=model_output, title="MODEL OUTPUT", level=LogLevel.INFO) - except ModelInvocationTerminalError: + except ModelInvocationTerminalError as terminal_error: + if terminal_error.error_code == ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED: + raise ModelOutputProtocolError( + reason=ProtocolErrorReason.EMPTY_VISIBLE_CONTENT, + protocol=getattr(self, "output_protocol", "code_action"), + logger=self.logger, + ) from terminal_error raise except Exception as e: if self.stop_event.is_set(): @@ -919,23 +966,57 @@ def rebuild_after_provider_overflow(): raise AgentGenerationError( f"Error in generating model output:\n{e}", self.logger) from e - self.logger.log_markdown( - content=model_output, title="Output message of the LLM:", level=LogLevel.DEBUG) + self.logger.log( + "Model output received " + f"(length={len(str(model_output or ''))}, " + f"unicode_categories={unicode_category_summary(model_output)})", + level=LogLevel.DEBUG, + ) if hitl is not None: hitl.generated(memory_step) - # Parse + # Parse using the configured closed output protocol. try: if self._use_structured_outputs_internally: code_action = json.loads(model_output)["code"] code_action = extract_code_from_text(code_action, self.code_block_tags) or code_action + classified_output = ExecutableAction(code=str(code_action)) else: - code_action = parse_code_blobs(model_output) + finish_reason = getattr(self.model, "last_finish_reason", None) + if finish_reason is None: + diagnostics = getattr(self.model, "last_response_diagnostics", None) or {} + finish_reason = diagnostics.get("finish_reason") + classified_output = classify_model_output( + model_output, + protocol=getattr(self, "output_protocol", "code_action"), + finish_reason=finish_reason, + logger=self.logger, + ) + if isinstance(classified_output, ExplicitFinalAnswer): + self._resolve_deferred_model_attempt(memory_step.model_output_message, accepted=True) + getattr(self, "_protocol_repair_messages", []).clear() + self._consecutive_protocol_errors = 0 + self._record_output_protocol("explicit_final_answer") + self.observer.add_message( + self.agent_name, ProcessType.STEP_COUNT, self.step_number) + memory_step.action_output = classified_output.answer + yield ActionOutput(output=classified_output.answer, is_final_answer=True) + return + + code_action = classified_output.code code_action = fix_final_answer_code(code_action) code_action = _remove_parallel_executor_import(code_action) memory_step.code_action = code_action + self._resolve_deferred_model_attempt(memory_step.model_output_message, accepted=True) + getattr(self, "_protocol_repair_messages", []).clear() + self._consecutive_protocol_errors = 0 + self._record_output_protocol( + "legacy_executable_action" if classified_output.legacy_format else "executable_action" + ) # Record parsing results + self.observer.add_message( + self.agent_name, ProcessType.STEP_COUNT, self.step_number) self.observer.add_message( self.agent_name, ProcessType.PARSE, code_action) verification_controller = getattr(self, "verification_controller", None) @@ -952,32 +1033,18 @@ def rebuild_after_provider_overflow(): self.logger, ) + except ModelOutputProtocolError: + self._resolve_deferred_model_attempt(memory_step.model_output_message, accepted=False) + raise except AgentExecutionError: raise except Exception: - if _looks_like_incomplete_action_output( - model_output, - available_tool_names=self._known_tool_names(), - finish_reason=getattr(self.model, "last_finish_reason", None), - ): - raise InvalidActionFormatError( - "The previous response described an action but ended before producing an executable tool call. " - "Do not treat an action preamble as the final answer. Emit executable Python inside " - "..., or return a complete user-facing final answer.", - self.logger, - ) - # Guard: if the model returned empty or whitespace-only content, - # treat it as a generation error so the retry loop can recover, - # instead of silently terminating the conversation with no output. - if not model_output or not str(model_output).strip(): - raise AgentGenerationError( - "Model returned empty or whitespace-only output; " - "this is likely a transient API issue and the step will be retried.", - self.logger, - ) - self.logger.log_markdown( - content=model_output, title="AGENT FINAL ANSWER", level=LogLevel.INFO) - raise FinalAnswerError() + self._resolve_deferred_model_attempt(memory_step.model_output_message, accepted=False) + raise ModelOutputProtocolError( + reason=ProtocolErrorReason.MALFORMED_ACTION, + protocol=self.output_protocol, + logger=self.logger, + ) tool_call = ToolCall( name="python_interpreter", @@ -1035,7 +1102,7 @@ def rebuild_after_provider_overflow(): refusal = pending_refusal or getattr(e, "refusal", "") self.verification_controller.pending_tool_block_refusal = None memory_step.model_output = refusal - raise FinalAnswerError() + raise RuntimeFinalAnswer(refusal, "guardrail_tool_input") exec_duration_ms = (time.time() - exec_start) * 1000 if hasattr(self.python_executor, "state") and "_print_outputs" in self.python_executor.state: execution_logs = str( @@ -1361,11 +1428,17 @@ def _run_stream( action_step = None hitl = getattr(self, "human_interaction", None) if hitl is not None and hitl.restored and hitl.completed_output is not None: + self._record_output_protocol( + "runtime_final_answer", + final_answer_source="hitl_restored", + ) yield FinalAnswerStep(handle_agent_output_types(hitl.completed_output)) return if hitl is None or not hitl.restored: self.step_number = 1 returned_final_answer = False + self._consecutive_protocol_errors = 0 + self._protocol_repair_messages: list[ChatMessage] = [] final_verification_round = hitl.final_verification_round if hitl is not None else 0 verification_config = getattr( self, @@ -1401,7 +1474,7 @@ def _run_stream( if isinstance(output, ActionOutput) and output.is_final_answer: candidate_answer = output.output - if candidate_answer is None or not str(candidate_answer).strip(): + if not has_meaningful_visible_content(candidate_answer): diagnostics = getattr(self.model, "last_response_diagnostics", None) logger.warning( "event=empty_final_answer_candidate source=final_answer_tool " @@ -1409,10 +1482,10 @@ def _run_stream( self.step_number, diagnostics, ) - raise AgentExecutionError( - "The final_answer tool returned empty content. Call final_answer again " - "with a non-empty user-facing response.", - self.logger, + raise ModelOutputProtocolError( + ProtocolErrorReason.EMPTY_VISIBLE_CONTENT, + getattr(self, "output_protocol", "code_action"), + logger=self.logger, ) self.logger.log( Text(f"Final answer: {candidate_answer}", style=f"bold {YELLOW_HEX}"), @@ -1433,6 +1506,15 @@ def _run_stream( self._validate_final_answer(final_answer) returned_final_answer = True action_step.is_final_answer = True + self._record_output_protocol( + "explicit_final_answer", + final_answer_source=( + "final_answer_envelope" + if getattr(self, "output_protocol", "code_action") + == "final_answer_envelope" + else "final_answer_tool" + ), + ) else: returned_final_answer, final_answer = self._finalize_failed_verification_candidate( action_step=action_step, @@ -1447,54 +1529,77 @@ def _run_stream( self._validate_final_answer(final_answer) returned_final_answer = True action_step.is_final_answer = True + self._record_output_protocol( + "explicit_final_answer", + final_answer_source=( + "final_answer_envelope" + if getattr(self, "output_protocol", "code_action") + == "final_answer_envelope" + else "final_answer_tool" + ), + ) - except FinalAnswerError: - # When the model does not output code, directly treat the large model content as the final answer - candidate_answer = action_step.model_output - if isinstance(candidate_answer, str): - candidate_answer = convert_code_format(candidate_answer) - if candidate_answer is None or not str(candidate_answer).strip(): - diagnostics = getattr(self.model, "last_response_diagnostics", None) - logger.warning( - "event=empty_final_answer_candidate source=direct_model_output " - "step_number=%s model_diagnostics=%s", - self.step_number, - diagnostics, - ) - action_step.error = AgentGenerationError( - "Model returned empty content instead of a final answer; the step will be retried.", - self.logger, - ) - continue + except RuntimeFinalAnswer as terminal: + final_answer = terminal.answer + returned_final_answer = True + action_step.is_final_answer = True + action_step.action_output = final_answer + self._record_output_protocol( + "runtime_final_answer", + final_answer_source=terminal.source, + ) - if verification_config.enabled and verification_config.final_verification_enabled: - final_verification_round += 1 - verification_result = self.verification_controller.verify_final_answer( - task=task, - candidate=candidate_answer, - memory_summary=self._build_verification_memory_summary(action_step), - round_number=final_verification_round, - ) - if verification_result.passed: - final_answer = candidate_answer - if self.final_answer_checks: - self._validate_final_answer(final_answer) - returned_final_answer = True - action_step.is_final_answer = True - else: - returned_final_answer, final_answer = self._finalize_failed_verification_candidate( - action_step=action_step, - verification_result=verification_result, - verification_round=final_verification_round, - max_rounds=max_final_verification_rounds, - candidate_answer=candidate_answer, - ) - else: - final_answer = candidate_answer + except ModelOutputProtocolError as protocol_error: + self._consecutive_protocol_errors += 1 + self._record_output_protocol( + "protocol_error", + reason=protocol_error.reason.value, + ) + logger.warning( + "event=model_output_protocol_error protocol=%s reason=%s step_number=%s " + "consecutive_errors=%s finish_reason=%s unicode_categories=%s", + getattr(self, "output_protocol", "code_action"), + protocol_error.reason.value, + self.step_number, + self._consecutive_protocol_errors, + getattr(getattr(self, "model", None), "last_finish_reason", None), + unicode_category_summary(getattr(action_step, "model_output", None)), + ) + action_has_executed = bool(getattr(action_step, "tool_calls", None)) + if action_has_executed: + # Preserve completed tool evidence so a repair generation cannot + # replay an external side effect. The model receives the error + # through memory, but the UI warning remains suppressed. + action_step.error = protocol_error + action_step._suppress_user_error = True + elif self._consecutive_protocol_errors >= 3: + self._protocol_repair_messages.clear() + action_step.model_output = None + action_step.model_output_message = None + final_answer = self._controlled_protocol_failure() returned_final_answer = True action_step.is_final_answer = True + action_step.action_output = final_answer + self._record_output_protocol( + "controlled_protocol_failure", + reason=protocol_error.reason.value, + final_answer_source="protocol_error_limit", + ) + else: + self._append_protocol_repair_context(protocol_error) + action_step.model_output = None + action_step.model_output_message = None + action_step.token_usage = None + interrupted = True + if hitl is not None: + hitl.completed_step(final_verification_round, None) + continue except StepSteered: + self._resolve_deferred_model_attempt( + getattr(action_step, "model_output_message", None), + accepted=False, + ) interrupted = True continue except ModelInvocationTerminalError: @@ -1505,6 +1610,10 @@ def _run_stream( interrupted = True raise except (AttemptSuspended, RecoveryRequired, RunTerminated): + self._resolve_deferred_model_attempt( + getattr(action_step, "model_output_message", None), + accepted=False, + ) interrupted = True raise except AgentError as e: @@ -1527,6 +1636,10 @@ def _run_stream( if self.stop_event.is_set(): final_answer = "" + self._record_output_protocol( + "runtime_final_answer", + final_answer_source="user_stop", + ) if not returned_final_answer and self.step_number == max_steps + 1: max_steps_data = json.dumps({ @@ -1539,19 +1652,12 @@ def _run_stream( # _handle_max_steps_reached already yields the final step internally # and sets action_step.error, so don't yield again to avoid duplicate error final_answer = self._handle_max_steps_reached(task) - if verification_config.enabled and verification_config.final_verification_enabled: - final_verification_round += 1 - verification_result = self.verification_controller.verify_final_answer( - task=task, - candidate=final_answer, - memory_summary=self._build_verification_memory_summary(), - round_number=final_verification_round, - ) - if not verification_result.passed: - final_answer = self.verification_controller.build_controlled_failure_answer( - final_answer, - verification_result, - ) + if not has_meaningful_visible_content(final_answer): + final_answer = self._controlled_protocol_failure() + self._record_output_protocol( + "runtime_final_answer", + final_answer_source="max_steps", + ) if hitl is not None: hitl.complete_run(final_answer) yield FinalAnswerStep(handle_agent_output_types(final_answer)) @@ -1724,7 +1830,7 @@ def rebuild_final_after_provider_overflow(): # Guard: if the model returned empty content at max-steps, provide a # meaningful fallback instead of an empty final_answer. - if not model_output or not str(model_output).strip(): + if not has_meaningful_visible_content(model_output): model_output = ( "The agent was unable to generate a valid response after reaching " "the maximum number of steps. Please try rephrasing your request." diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index 77746eef0e..d4b14894d5 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -912,6 +912,7 @@ def create_single_agent( user_id=self.user_id, executor=python_executor, verification_config=getattr(agent_config, "verification_config", None), + output_protocol=getattr(agent_config, "output_protocol", "code_action"), workspace_path=self.workspace_path, ) agent.stop_event = self.stop_event @@ -1143,7 +1144,11 @@ def agent_run_with_observer( }) observer.add_message("", ProcessType.TOKEN_COUNT, json.dumps(token_data)) - if hasattr(step_log, "error") and step_log.error is not None: + if ( + hasattr(step_log, "error") + and step_log.error is not None + and not getattr(step_log, "_suppress_user_error", False) + ): # Action-step failures are observations in the ReAct loop: # the model receives them and can repair/retry on the next # step. Surface them as warnings so the UI does not imply diff --git a/sdk/nexent/core/agents/output_protocol.py b/sdk/nexent/core/agents/output_protocol.py new file mode 100644 index 0000000000..9ecb91fbab --- /dev/null +++ b/sdk/nexent/core/agents/output_protocol.py @@ -0,0 +1,204 @@ +"""Closed output protocol for Nexent CodeAgent runtimes.""" + +from __future__ import annotations + +import ast +import re +import unicodedata +from dataclasses import dataclass +from enum import Enum +from typing import Any, Literal + + +OutputProtocol = Literal["code_action", "final_answer_envelope"] + + +class ProtocolErrorReason(str, Enum): + """Stable classifications for invalid model output.""" + + EMPTY_VISIBLE_CONTENT = "empty_visible_content" + UNSUPPORTED_OR_TAG_ONLY_OUTPUT = "unsupported_or_tag_only_output" + MALFORMED_ACTION = "malformed_action" + MISSING_EXPLICIT_TERMINATION = "missing_explicit_termination" + TRUNCATED_GENERATION = "truncated_generation" + INVALID_FINAL_ENVELOPE = "invalid_final_envelope" + + +@dataclass(frozen=True) +class ExecutableAction: + """One validated Python action ready for the executor.""" + + code: str + legacy_format: bool = False + + +@dataclass(frozen=True) +class ExplicitFinalAnswer: + """One validated NL2Skill final-answer envelope payload.""" + + answer: str + + +class ModelOutputProtocolError(Exception): + """A recoverable model-output protocol violation.""" + + def __init__( + self, + reason: ProtocolErrorReason, + protocol: OutputProtocol, + logger: Any = None, + ) -> None: + self.reason = reason + self.protocol = protocol + self.repair_instruction = protocol_repair_instruction(protocol, reason) + super().__init__(self.repair_instruction) + self.message = self.repair_instruction + + +class RuntimeFinalAnswer(Exception): + """A trusted runtime-controlled terminal answer.""" + + def __init__(self, answer: Any, source: str) -> None: + super().__init__(source) + self.answer = answer + self.source = source + + +_CODE_RE = re.compile(r"\A(?P[\s\S]*)\Z") +_RUN_RE = re.compile(r"\A```(?P[\s\S]*?)```\Z") +_FINAL_ENVELOPE_RE = re.compile(r"\A(?P[\s\S]*)\Z") +_TAG_RE = re.compile(r"]{0,255}>") + + +def _is_protocol_padding(character: str) -> bool: + return character.isspace() or unicodedata.category(character) == "Cf" + + +def strip_protocol_padding(value: Any) -> str: + """Strip only protocol-ignorable edge whitespace and format characters.""" + + text = "" if value is None else str(value) + start = 0 + end = len(text) + while start < end and _is_protocol_padding(text[start]): + start += 1 + while end > start and _is_protocol_padding(text[end - 1]): + end -= 1 + return text[start:end] + + +def has_meaningful_visible_content(value: Any) -> bool: + """Return whether content contains a non-whitespace, non-format character.""" + + if value is None: + return False + return any(not _is_protocol_padding(character) for character in str(value)) + + +def unicode_category_summary(value: Any) -> str: + """Return a content-free Unicode category count for diagnostics.""" + + counts: dict[str, int] = {} + for character in str(value or ""): + category = unicodedata.category(character) + counts[category] = counts.get(category, 0) + 1 + return ",".join(f"{key}:{counts[key]}" for key in sorted(counts)) or "empty" + + +def protocol_repair_instruction( + protocol: OutputProtocol, + reason: ProtocolErrorReason, +) -> str: + """Build safe feedback that teaches only the configured runtime protocol.""" + + prefix = f"The previous response violated the Agent output protocol ({reason.value}). " + if protocol == "final_answer_envelope": + return ( + prefix + "Return exactly one complete ... envelope. " + "Put the required , , and content inside it, with no content outside the envelope." + ) + return ( + prefix + "Return exactly one executable Python action inside .... " + "To finish, call final_answer(...) inside that code block; never return a bare-text final answer." + ) + + +def _raise_protocol_error( + reason: ProtocolErrorReason, + protocol: OutputProtocol, + logger: Any, +) -> None: + raise ModelOutputProtocolError(reason, protocol, logger) + + +def classify_model_output( + output: Any, + *, + protocol: OutputProtocol, + finish_reason: str | None = None, + logger: Any = None, +) -> ExecutableAction | ExplicitFinalAnswer: + """Classify one complete model response using a closed runtime protocol.""" + + if protocol not in ("code_action", "final_answer_envelope"): + raise ValueError(f"Unsupported output protocol: {protocol}") + if finish_reason == "length": + _raise_protocol_error(ProtocolErrorReason.TRUNCATED_GENERATION, protocol, logger) + + text = strip_protocol_padding(output) + if not has_meaningful_visible_content(text): + _raise_protocol_error(ProtocolErrorReason.EMPTY_VISIBLE_CONTENT, protocol, logger) + + if protocol == "code_action": + code_match = _CODE_RE.fullmatch(text) + if code_match: + code = code_match.group("body").strip() + if not has_meaningful_visible_content(code): + _raise_protocol_error(ProtocolErrorReason.MALFORMED_ACTION, protocol, logger) + try: + ast.parse(code) + except (SyntaxError, ValueError, TypeError): + _raise_protocol_error(ProtocolErrorReason.MALFORMED_ACTION, protocol, logger) + return ExecutableAction(code=code) + + run_match = _RUN_RE.fullmatch(text) + if run_match and text.count("```") == 1: + code = run_match.group("body").strip() + if not has_meaningful_visible_content(code): + _raise_protocol_error(ProtocolErrorReason.MALFORMED_ACTION, protocol, logger) + try: + ast.parse(code) + except (SyntaxError, ValueError, TypeError): + _raise_protocol_error(ProtocolErrorReason.MALFORMED_ACTION, protocol, logger) + return ExecutableAction(code=code, legacy_format=True) + + if any(marker in text for marker in ("", "", "```")): + _raise_protocol_error(ProtocolErrorReason.MALFORMED_ACTION, protocol, logger) + if _TAG_RE.search(text): + _raise_protocol_error( + ProtocolErrorReason.UNSUPPORTED_OR_TAG_ONLY_OUTPUT, + protocol, + logger, + ) + _raise_protocol_error( + ProtocolErrorReason.MISSING_EXPLICIT_TERMINATION, + protocol, + logger, + ) + + envelope_match = _FINAL_ENVELOPE_RE.fullmatch(text) + if envelope_match and text.count("") == 1 and text.count("") == 1: + answer = envelope_match.group("body") + if not has_meaningful_visible_content(answer): + _raise_protocol_error( + ProtocolErrorReason.INVALID_FINAL_ENVELOPE, + protocol, + logger, + ) + return ExplicitFinalAnswer(answer=answer) + + _raise_protocol_error( + ProtocolErrorReason.INVALID_FINAL_ENVELOPE, + protocol, + logger, + ) diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 295d02c6bd..1fa7192599 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -120,6 +120,8 @@ def _is_timeout_error(exc: BaseException) -> bool: class OpenAIModel(OpenAIServerModel): + supports_deferred_attempt_commit = True + # Public SDK constructor: keep common kwargs explicit and read extension # kwargs below to preserve backward-compatible keyword call sites. def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, top_p=0.95, @@ -278,6 +280,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List _token_tracker=None, context_budget_snapshot: Optional[ContextBudgetSnapshot] = None, context_rebuild=None, _overflow_recovery_ordinal: int = 0, _model_attempts_used: int = 0, + _defer_attempt_commit: bool = False, **kwargs, ) -> ChatMessage: _monitoring_operation.set("chat_completion") @@ -321,6 +324,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List context_rebuild=context_rebuild, _overflow_recovery_ordinal=_overflow_recovery_ordinal, _model_attempts_used=_model_attempts_used, + _defer_attempt_commit=_defer_attempt_commit, **kwargs, ) @@ -721,10 +725,19 @@ def _close_stream_once(): ) message.raw = current_request message.role = MessageRole.ASSISTANT - commit_attempt = getattr(self.observer, "commit_model_attempt", None) - if callable(commit_attempt): - commit_attempt(attempt_id, attempt) - self._monitoring.add_span_event("model_attempt_commit", { + message.model_attempt_id = attempt_id + message.model_attempt_number = attempt + message.model_attempt_commit_deferred = _defer_attempt_commit + attempt_event = ( + "model_attempt_commit_deferred" + if _defer_attempt_commit + else "model_attempt_commit" + ) + if not _defer_attempt_commit: + commit_attempt = getattr(self.observer, "commit_model_attempt", None) + if callable(commit_attempt): + commit_attempt(attempt_id, attempt) + self._monitoring.add_span_event(attempt_event, { "attempt_id": attempt_id, "attempt": attempt, }) @@ -839,6 +852,7 @@ def _close_stream_once(): context_rebuild=context_rebuild, _overflow_recovery_ordinal=_overflow_recovery_ordinal + 1, _model_attempts_used=attempt, + _defer_attempt_commit=_defer_attempt_commit, **kwargs, ) is_timeout = _is_timeout_error(e) diff --git a/sdk/nexent/core/tools/create_scheduled_task_tool.py b/sdk/nexent/core/tools/create_scheduled_task_tool.py index 230806c633..742d382d4b 100644 --- a/sdk/nexent/core/tools/create_scheduled_task_tool.py +++ b/sdk/nexent/core/tools/create_scheduled_task_tool.py @@ -28,14 +28,14 @@ class CreateScheduledTaskProposalTool(Tool): "Create a pending scheduled-task proposal when the user explicitly asks " "for a task to run later or repeatedly. Pass the user's scheduling request " "verbatim. This tool only extracts and saves a proposal for user confirmation; " - "it never executes the business task. Call it as the only action in the code " - "block and return its result directly as the final answer." + "it never executes the business task. Make it the only business-tool call in " + "the code block and pass its result to final_answer(...)." ) description_zh = ( "当用户明确要求未来、延迟或周期性执行任务时,创建一个待确认的" "定时任务提案。request_text 必须原样传入用户的定时执行请求。" "此工具只提取并保存待用户确认的提案,不会立即执行业务任务。" - "调用时它必须是代码块中的唯一动作,并将返回结果直接作为最终回答。" + "它必须是代码块中唯一的业务工具调用,并将其结果传给 final_answer(...)。" ) inputs = { "request_text": { diff --git a/test/assets/test_prompt.yaml b/test/assets/test_prompt.yaml index 38c9debeb0..14e6774cd5 100644 --- a/test/assets/test_prompt.yaml +++ b/test/assets/test_prompt.yaml @@ -21,10 +21,10 @@ system_prompt: |- - 查看代码执行结果 - 根据结果决定下一步行动 - 在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 + 在思考结束后,当你认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。 生成最终回答时,你需要遵顼以下规范: - 1.不要输出代码,因为最终回答不应该包含任何代码。 + 1.除非用户要求展示代码,否则 `final_answer(...)` 的正文不应包含代码。 2.使用Markdown格式格式化你的输出。 3.在回答的对应位置添加引用标记,格式为'[[1]][[2]]'。注意仅添加引用标记,不需要添加链接、参考文献等多余内容。 @@ -99,4 +99,4 @@ managed_agent: task: |- report: |- - {{final_answer}} \ No newline at end of file + {{final_answer}} diff --git a/test/assets/test_sub_prompt.yaml b/test/assets/test_sub_prompt.yaml index a06b886dea..9893180888 100644 --- a/test/assets/test_sub_prompt.yaml +++ b/test/assets/test_sub_prompt.yaml @@ -19,10 +19,10 @@ system_prompt: |- 3. 观察结果: - 查看代码执行结果 - 在思考结束后,当你认为可以回答用户问题,那么可以不生成代码,直接生成最终回答给到用户并停止循环。 + 在思考结束后,当你认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。 生成最终回答时,你需要遵顼以下规范: - 1.不要输出代码,因为最终回答不应该包含任何代码。 + 1.除非用户要求展示代码,否则 `final_answer(...)` 的正文不应包含代码。 2.使用Markdown格式格式化你的输出。 3.在回答的对应位置添加引用标记,格式为'[[index]]',其中index为引用的序号。注意仅添加引用标记,不需要添加链接、参考文献等多余内容。 @@ -131,4 +131,4 @@ managed_agent: 即使你的任务解决不成功,也请返回尽可能多的上下文,这样你的管理者可以根据这个反馈采取行动。 report: |- - {{final_answer}} \ No newline at end of file + {{final_answer}} diff --git a/test/backend/services/test_nl2skill_service.py b/test/backend/services/test_nl2skill_service.py index 8f4dada445..42d12ed338 100644 --- a/test/backend/services/test_nl2skill_service.py +++ b/test/backend/services/test_nl2skill_service.py @@ -31,6 +31,7 @@ def test_create_nl2skill_agent_config_sets_ephemeral_runtime_options(): assert config.instructions == "system" assert config.tools == [] assert config.max_steps == 5 + assert config.output_protocol == "final_answer_envelope" assert config.provide_run_summary is False assert config.enable_planning is False @@ -202,13 +203,14 @@ async def test_stream_preserves_raw_types_and_emits_semantic_events(mocker): async def fake_agent_run(_run_info, *, thread_manager): assert thread_manager is not None chunks = [ - {"type": "model_thinking_output", "content": "Preparing.\n\n\n---\nname: demo\ndescription: Demo\ntags: [demo]\n---\n# Demo\n\n", }, {"type": "model_output_code", "content": '\nprint("ok")\n\n'}, - {"type": "model_output_thinking", "content": "\nReady.\n\n"}, + {"type": "model_output_thinking", "content": "\nReady.\n\n"}, {"type": "final_answer", "content": "duplicate"}, ] for chunk in chunks: @@ -231,6 +233,7 @@ async def fake_agent_run(_run_info, *, thread_manager): for item in payloads ) assert any(item["type"] == "summary" for item in payloads) + assert not any("FINAL_ANSWER" in item.get("content", "") for item in payloads) assert not any(item.get("content") == "duplicate" for item in payloads) assert payloads[-1]["type"] == "done" assert stop_event.is_set() diff --git a/test/backend/utils/test_content_classifier_utils.py b/test/backend/utils/test_content_classifier_utils.py index 182bbfb802..4ba4b17719 100644 --- a/test/backend/utils/test_content_classifier_utils.py +++ b/test/backend/utils/test_content_classifier_utils.py @@ -8,6 +8,32 @@ class TestContentClassifier: """Test cases for ContentClassifier.""" + def test_ac_010_final_answer_envelope_is_consumed(self): + classifier = ContentClassifier() + + events = classifier.classify( + "\n\n# Demo\n\n" + "\nCreated.\n\n", + origin_type="model_output", + ) + events.extend(classifier.flush()) + + assert all("FINAL_ANSWER" not in event.get("content", "") for event in events) + assert any(event["type"] == "skill_body" and "# Demo" in event["content"] for event in events) + assert any(event["type"] == "summary" and "Created." in event["content"] for event in events) + assert classifier.saw_control_tag is True + + def test_ac_010_final_answer_envelope_can_be_adjacent_to_payload(self): + classifier = ContentClassifier() + + events = classifier.classify( + "clarification?", + origin_type="model_output", + ) + events.extend(classifier.flush()) + + assert "".join(event.get("content", "") for event in events) == "clarification?" + def test_basic_classification(self): """Test basic content classification.""" classifier = ContentClassifier() diff --git a/test/sdk/core/agents/test_agent_model.py b/test/sdk/core/agents/test_agent_model.py index b9ef26b814..eaa43864ac 100644 --- a/test/sdk/core/agents/test_agent_model.py +++ b/test/sdk/core/agents/test_agent_model.py @@ -1256,6 +1256,7 @@ def test_agent_config_defaults(self): ) assert config.prompt_templates is None assert config.max_steps == 15 + assert config.output_protocol == "code_action" assert config.provide_run_summary is False assert config.instructions is None assert config.managed_agents == [] diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index 0968b2aaf4..e431e5ac61 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -401,51 +401,6 @@ def test_provider_overflow_recovery_is_disabled_after_a_tool_call(): # ---------------------------------------------------------------------------- -def test_incomplete_action_preamble_is_not_a_final_answer(): - output = "思考:我需要先调用 knowledge_base_search 检索当前选择的知识库。" - - assert core_agent_module._looks_like_incomplete_action_output( - output, - available_tool_names={"knowledge_base_search"}, - ) is True - - -def test_complete_answer_that_names_tool_is_not_misclassified(): - output = "knowledge_base_search 是用于检索知识库的工具。" - - assert core_agent_module._looks_like_incomplete_action_output( - output, - available_tool_names={"knowledge_base_search"}, - ) is False - - -@pytest.mark.parametrize( - "output", - [ - ( - "思考:工具调用成功。根据策略,我需要用 `final_answer` 返回工具结果。\n\n" - "最终回答:\n定时任务提案已生成,请核对任务内容和执行时间后确认创建。" - ), - ( - "Analysis: The tool call succeeded, so I will use `final_answer` to return the result.\n\n" - "Final answer:\nThe scheduled-task proposal is ready for confirmation." - ), - ], -) -def test_complete_explicit_final_answer_is_not_misclassified(output): - assert core_agent_module._looks_like_incomplete_action_output( - output, - available_tool_names={"final_answer", "create_scheduled_task_proposal"}, - ) is False - - -def test_length_truncated_non_code_output_is_not_a_final_answer(): - assert core_agent_module._looks_like_incomplete_action_output( - "这是一个尚未完成的回答", - finish_reason="length", - ) is True - - def test_parse_code_blobs_run_format(): """Test parse_code_blobs with ... pattern (new format).""" text = """Here is some code: @@ -1040,62 +995,18 @@ def test_convert_code_format_mixed_with_code(): # ---------------------------------------------------------------------------- -# Tests for FinalAnswerError exception class +# Tests for trusted runtime final answers # ---------------------------------------------------------------------------- -def test_final_answer_error_creation(): - """Test FinalAnswerError can be created and raised.""" - error = core_agent_module.FinalAnswerError() - assert isinstance(error, Exception) - with pytest.raises(core_agent_module.FinalAnswerError): +def test_runtime_final_answer_creation(): + """Trusted runtime final answers carry their content and source.""" + error = core_agent_module.RuntimeFinalAnswer("refusal", "guardrail_input") + assert error.answer == "refusal" + assert error.source == "guardrail_input" + with pytest.raises(core_agent_module.RuntimeFinalAnswer): raise error -@pytest.mark.parametrize( - "output", - [ - "Step 2:\nCalled tool 'python_interpreter'()", - "### Step 2:\n- Called tool 'python_interpreter'()", - "Observation: previous result", - '{"tool_calls":[{"name":"python_interpreter","arguments":"print(1)"}]}', - '```json\n{"action":"search","arguments":{"q":"GAIA"}}\n```', - "print('missing closing tag')", - ], -) -def test_action_like_non_executable_output_is_not_a_final_answer(output): - assert core_agent_module._looks_like_invalid_action_output(output) is True - - -@pytest.mark.parametrize( - "output", - [ - None, - 42, - "", - " ", - "The answer is 42.", - "I could not find enough evidence to answer.", - '{"answer":"42"}', - "{not valid json", - "```json```", - '["not an action record"]', - ], -) -def test_plain_answer_does_not_look_like_invalid_action(output): - assert core_agent_module._looks_like_invalid_action_output(output) is False - - -@pytest.mark.parametrize( - "output", - [ - "```print('missing closing fence')", - '[{"action":"search","arguments":{"q":"GAIA"}}]', - ], -) -def test_additional_action_protocol_variants_are_invalid(output): - assert core_agent_module._looks_like_invalid_action_output(output) is True - - # ---------------------------------------------------------------------------- # Additional edge case tests for parse_code_blobs # ---------------------------------------------------------------------------- @@ -2318,7 +2229,7 @@ def test_step_stream_uses_context_runtime_for_uncompressed_est(self): generator = agent._step_stream(action_step) try: next(generator) - except (StopIteration, ValueError): + except (StopIteration, ValueError, module.ModelOutputProtocolError): pass assert agent._last_uncompressed_est == 5000 @@ -2358,7 +2269,7 @@ def invoke_rebuild(messages, **kwargs): stream = agent._step_stream(action_step) try: list(stream) - except (ValueError, TypeError): + except (ValueError, TypeError, module.ModelOutputProtocolError): # Parsing the synthetic response is outside this callback contract test. pass @@ -2405,7 +2316,7 @@ def test_step_stream_falls_back_without_uncompressed_runtime_count(self): generator = agent._step_stream(action_step) try: next(generator) - except (StopIteration, ValueError): + except (StopIteration, ValueError, module.ModelOutputProtocolError): pass # When the runtime has no raw count, fall back to msg_token_count. @@ -2441,11 +2352,171 @@ def test_step_stream_rejects_whitespace_only_model_output(self, monkeypatch): action_step = MagicMock() stream = agent._step_stream(action_step) - with pytest.raises(Exception, match="empty or whitespace-only output"): + with pytest.raises(module.ModelOutputProtocolError) as exc_info: next(stream) + assert exc_info.value.reason == module.ProtocolErrorReason.EMPTY_VISIBLE_CONTENT assert action_step.model_output == " \n\t" + def test_step_stream_turns_exhausted_empty_response_into_silent_protocol_repair( + self, monkeypatch + ): + """Physical empty retries hand off to the hidden semantic repair loop.""" + module = core_agent_module + CoreAgent = module.CoreAgent + monkeypatch.setattr(module, "AgentGenerationError", type("AgentGenerationError", (Exception,), {})) + + agent = object.__new__(CoreAgent) + agent.agent_name = "test" + agent.observer = MagicMock() + agent.step_number = 1 + agent.memory = MagicMock(steps=[]) + agent.logger = MagicMock() + agent.context_runtime = self._context_runtime_mock() + final_context = MagicMock() + final_context.messages = [MagicMock()] + agent.context_runtime.prepare_step.return_value = final_context + agent._history_step_count = 0 + agent._context_tools = MagicMock(return_value=[]) + agent._use_structured_outputs_internally = False + agent.output_protocol = "code_action" + agent.verification_controller = None + terminal = module.ModelInvocationTerminalError( + module.ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED, + 5, + cause=RuntimeError("provider detail"), + ) + agent.model = MagicMock(side_effect=terminal) + + action_step = MagicMock(model_output=None) + with pytest.raises(module.ModelOutputProtocolError) as exc_info: + next(agent._step_stream(action_step)) + + assert exc_info.value.reason == module.ProtocolErrorReason.EMPTY_VISIBLE_CONTENT + assert exc_info.value.__cause__ is terminal + + def test_step_stream_closed_protocol_omits_legacy_provider_stop_sequences( + self, monkeypatch + ): + """Provider-side stops cannot erase a reasoning model's opening prefix.""" + module = core_agent_module + agent = object.__new__(module.CoreAgent) + agent.agent_name = "test" + agent.observer = MagicMock() + agent.step_number = 1 + agent.memory = MagicMock(steps=[]) + agent.logger = MagicMock() + agent.context_runtime = self._context_runtime_mock() + final_context = MagicMock() + final_context.messages = [MagicMock()] + agent.context_runtime.prepare_step.return_value = final_context + agent._history_step_count = 0 + agent._context_tools = MagicMock(return_value=[]) + agent._use_structured_outputs_internally = False + agent._protocol_repair_messages = [] + agent.output_protocol = "final_answer_envelope" + agent.verification_controller = None + response = SimpleNamespace( + content="ok", + token_usage=None, + ) + agent.model = MagicMock(return_value=response) + + next(agent._step_stream(MagicMock(model_output=None))) + + assert agent.model.call_args.kwargs["stop_sequences"] is None + + def test_step_stream_opens_turn_after_completed_assistant_action(self, monkeypatch): + """A completed action cannot leave chat completion ending in assistant.""" + module = core_agent_module + agent = object.__new__(module.CoreAgent) + agent.agent_name = "test" + agent.observer = MagicMock() + agent.step_number = 2 + agent.memory = MagicMock(steps=[]) + agent.logger = MagicMock() + agent.context_runtime = self._context_runtime_mock() + final_context = MagicMock() + final_context.messages = [ + {"role": "user", "content": "task"}, + {"role": "assistant", "content": "completed action"}, + ] + agent.context_runtime.prepare_step.return_value = final_context + agent._history_step_count = 0 + agent._context_tools = MagicMock(return_value=[]) + agent._use_structured_outputs_internally = False + agent._protocol_repair_messages = [] + agent.output_protocol = "final_answer_envelope" + agent.verification_controller = None + agent.model = MagicMock( + return_value=SimpleNamespace( + content="ok", + token_usage=None, + ) + ) + + next(agent._step_stream(MagicMock(model_output=None))) + + actual_messages = agent.model.call_args.args[0] + assert len(actual_messages) == 3 + continuation = module.ChatMessage.call_args.kwargs["content"][0]["text"] + assert "Do not repeat any completed action" in continuation + assert "" in continuation + + def test_step_stream_rolls_back_deferred_attempt_before_protocol_repair(self): + """Rejected model text is rolled back before CoreAgent asks for repair.""" + module = core_agent_module + agent = object.__new__(module.CoreAgent) + agent.agent_name = "test" + agent.observer = MagicMock() + agent.step_number = 1 + agent.memory = MagicMock(steps=[]) + agent.logger = MagicMock() + agent.context_runtime = self._context_runtime_mock() + final_context = MagicMock() + final_context.messages = [MagicMock()] + agent.context_runtime.prepare_step.return_value = final_context + agent._history_step_count = 0 + agent._context_tools = MagicMock(return_value=[]) + agent._use_structured_outputs_internally = False + agent._protocol_repair_messages = [] + agent.output_protocol = "code_action" + agent.verification_controller = None + + response = SimpleNamespace( + content='prefix\nfinal_answer("ok")', + token_usage=None, + model_attempt_id="semantic-attempt", + model_attempt_number=1, + model_attempt_commit_deferred=True, + ) + model = MagicMock(return_value=response) + model.supports_deferred_attempt_commit = True + model.last_finish_reason = "stop" + model.last_response_diagnostics = {"finish_reason": "stop"} + agent.model = model + + action_step = SimpleNamespace( + model_output=None, + model_output_message=None, + token_usage=None, + model_input_messages=None, + ) + + with pytest.raises(module.ModelOutputProtocolError): + list(agent._step_stream(action_step)) + + assert model.call_args.kwargs["_defer_attempt_commit"] is True + agent.observer.rollback_model_attempt.assert_called_once_with( + "semantic-attempt", 1 + ) + agent.observer.commit_model_attempt.assert_not_called() + assert response.model_attempt_commit_deferred is False + assert all( + call_.args[1] is not module.ProcessType.STEP_COUNT + for call_ in agent.observer.add_message.call_args_list + ) + def test_run_stream_stop_event_path_real_execution(self): """Test _run_stream with stop_event set (user break).""" import threading @@ -2588,95 +2659,23 @@ def mock_step_stream(action_step): max_steps_calls = [c for c in observer_calls if c[1] == TestProcessType.MAX_STEPS_REACHED] assert len(max_steps_calls) == 0 - def test_run_stream_final_answer_error_path(self): - """Test _run_stream when FinalAnswerError is raised.""" - # This covers the code path where the model outputs non-code text (FinalAnswerError) - - # Create ProcessType - class TestProcessType: - MAX_STEPS_REACHED = "MAX_STEPS_REACHED" - - # Track observer calls - observer_calls = [] - - # Load CoreAgent - module = self._load_core_agent_in_isolation() - CoreAgent = module.CoreAgent - - # Verify it's a real class - assert not isinstance(CoreAgent, MagicMock) - - # Get FinalAnswerError from the loaded module - FinalAnswerError = module.FinalAnswerError - - # Create mock memory - mock_memory = MagicMock() - mock_memory.steps = [] - - # Create stop_event not set - stop_event = MagicMock() - stop_event.is_set = lambda: False - - # Track step_stream calls - step_stream_calls = [0] - - # Create mock ActionStep with model_output - mock_action_step = MagicMock() - mock_action_step.model_output = "This is my final answer" - mock_action_step.is_final_answer = True - - # Create step_stream that raises FinalAnswerError - def mock_step_stream(action_step): - step_stream_calls[0] += 1 - # Return the mock action step that has model_output - yield mock_action_step - # Then raise FinalAnswerError to trigger the except block - raise FinalAnswerError() + def test_run_stream_trusted_runtime_final_path(self, monkeypatch): + """A trusted runtime refusal terminates without model-final verification.""" + module = core_agent_module + agent = self._create_canonical_run_agent(monkeypatch) + agent.verification_controller = MagicMock() - # Create agent - agent = object.__new__(CoreAgent) - agent.agent_name = "test_agent" - agent.observer = MagicMock() - agent.observer.add_message = lambda *args: observer_calls.append(args) - agent.stop_event = stop_event - agent.step_number = 1 - agent.memory = mock_memory - agent.logger = MagicMock() - agent.logger.log = lambda *args, **kwargs: None - agent.monitor = MagicMock() - agent.max_steps = 10 - agent.name = "test_agent" - agent.task = "test task" - agent.state = {} - agent.final_answer_checks = None - agent.return_full_result = False - agent.python_executor = MagicMock() - agent.model = MagicMock() - agent.prompt_templates = {} - agent.tools = {} - agent.managed_agents = {} - agent.provide_run_summary = False - agent._use_structured_outputs_internally = False - agent.context_runtime = self._context_runtime_mock() - agent.step_metrics = [] + def mock_step_stream(_action_step): + if False: + yield None + raise module.RuntimeFinalAnswer("refusal", "guardrail_input") agent._step_stream = mock_step_stream - agent._handle_max_steps_reached = MagicMock(return_value="Max steps") - agent._finalize_step = lambda x: None - - # Call _run_stream - generator = agent._run_stream("test task", max_steps=10) + results = list(agent._run_stream("test task", max_steps=3)) - # Consume the generator - try: - results = list(generator) - except FinalAnswerError: - # The generator may raise FinalAnswerError - that's okay - pass - - # FinalAnswerError path should prevent MAX_STEPS_REACHED - max_steps_calls = [c for c in observer_calls if c[1] == TestProcessType.MAX_STEPS_REACHED] - assert len(max_steps_calls) == 0 + assert results[-1].output == "refusal" + assert len(agent.memory.steps) == 1 + agent.verification_controller.verify_final_answer.assert_not_called() def test_run_stream_retries_empty_final_answer_tool_result(self, monkeypatch): """An empty final_answer tool result must not end the run successfully.""" @@ -2712,8 +2711,46 @@ def mock_step_stream(_action_step): results = list(agent._run_stream("test task", max_steps=2)) assert results[-1].output == "valid answer" + assert len(agent.memory.steps) == 1 + assert getattr(agent.memory.steps[0], "error", None) is None + assert agent.memory.steps[0].step_number == 1 + + def test_protocol_repair_retains_executed_action_without_user_warning(self, monkeypatch): + """A post-execution protocol error keeps tool evidence to prevent replay.""" + module = core_agent_module + + class FakeAgentError(Exception): + pass + + class FakeActionOutput: + def __init__(self, output, is_final_answer): + self.output = output + self.is_final_answer = is_final_answer + + monkeypatch.setattr(module, "AgentError", FakeAgentError) + monkeypatch.setattr(module, "ActionOutput", FakeActionOutput) + agent = self._create_canonical_run_agent(monkeypatch) + calls = 0 + + def mock_step_stream(action_step): + nonlocal calls + calls += 1 + if calls == 1: + action_step.tool_calls = [SimpleNamespace(id="executed")] + raise module.ModelOutputProtocolError( + module.ProtocolErrorReason.EMPTY_VISIBLE_CONTENT, + "code_action", + ) + yield FakeActionOutput(output="done", is_final_answer=True) + + agent._step_stream = mock_step_stream + results = list(agent._run_stream("test task", max_steps=2)) + + assert results[-1].output == "done" assert len(agent.memory.steps) == 2 - assert agent.memory.steps[0].error is not None + assert agent.memory.steps[0].tool_calls[0].id == "executed" + assert agent.memory.steps[0]._suppress_user_error is True + assert agent.memory.steps[1].step_number == 2 def test_cmsr_004_terminal_model_error_stops_react_without_memory_append( self, monkeypatch @@ -2751,14 +2788,14 @@ def failing_step(_action_step): agent._finalize_step.assert_not_called() agent._collect_step_metrics.assert_not_called() - def test_planning_run_retries_empty_direct_answer_then_verifies_valid_answer(self, monkeypatch): - """Planning runs reset state, retry an empty answer, and verify the next answer.""" + def test_planning_run_stops_after_three_protocol_errors(self, monkeypatch): + """Planning state resets and three invalid generations fail safely.""" module = core_agent_module agent = self._create_canonical_run_agent( monkeypatch, enable_planning=True, - model=MagicMock(last_response_diagnostics={"finish_reason": "length"}), + model=MagicMock(last_response_diagnostics={"finish_reason": "stop"}), verification_config=SimpleNamespace( enabled=True, final_verification_enabled=True, @@ -2768,32 +2805,26 @@ def test_planning_run_retries_empty_direct_answer_then_verifies_valid_answer(sel agent.current_plan = "stale plan" agent.current_step_index = 99 agent.verification_controller = MagicMock() - agent.verification_controller.verify_final_answer.return_value = SimpleNamespace( - passed=True - ) - agent._build_verification_memory_summary = MagicMock(return_value="summary") - - direct_answers = iter([" \n", "valid direct answer"]) - def mock_step_stream(action_step): - action_step.model_output = next(direct_answers) + action_step.model_output = "bare text" if False: yield None - raise module.FinalAnswerError() + raise module.ModelOutputProtocolError( + module.ProtocolErrorReason.MISSING_EXPLICIT_TERMINATION, + "code_action", + ) agent._step_stream = mock_step_stream - results = list(agent._run_stream("test task", max_steps=2)) + results = list(agent._run_stream("test task", max_steps=5)) - assert results[-1].output == "valid direct answer" + assert "failed to follow" in results[-1].output assert agent.current_plan is None assert agent.current_step_index == 0 - assert len(agent.memory.steps) == 2 - assert agent.memory.steps[0].error is not None - agent.verification_controller.verify_final_answer.assert_called_once() - assert agent.verification_controller.verify_final_answer.call_args.kwargs[ - "candidate" - ] == "valid direct answer" + assert len(agent.memory.steps) == 1 + assert getattr(agent.memory.steps[0], "error", None) is None + assert agent.memory.steps[0].step_number == 1 + agent.verification_controller.verify_final_answer.assert_not_called() # ---------------------------------------------------------------------------- # Tests for _handle_max_steps_reached method diff --git a/test/sdk/core/agents/test_core_agent_planning.py b/test/sdk/core/agents/test_core_agent_planning.py index d0f610e7ed..ef832e4430 100644 --- a/test/sdk/core/agents/test_core_agent_planning.py +++ b/test/sdk/core/agents/test_core_agent_planning.py @@ -9,15 +9,11 @@ """ import importlib.util -import json import sys -import types from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock -import pytest - REPO_ROOT = Path(__file__).resolve().parents[4] @@ -215,6 +211,10 @@ def __init__(self): token_mod = _sdk_pkg("sdk.nexent.core.utils.token_estimation") token_mod.msg_token_count = lambda *a, **k: 0 +# context budget helper stub +budget_mod = _sdk_pkg("sdk.nexent.core.agents.context.budget") +budget_mod.message_role = lambda message: getattr(message, "role", "") + # verification stub verification_mod = _sdk_pkg("sdk.nexent.core.agents.verification") @@ -275,13 +275,22 @@ class _VerificationResult: # ---- Load core_agent under controlled sys.modules ----------------- +OUTPUT_PROTOCOL_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "output_protocol.py" +OUTPUT_PROTOCOL_NAME = "sdk.nexent.core.agents.output_protocol" +output_protocol_spec = importlib.util.spec_from_file_location(OUTPUT_PROTOCOL_NAME, OUTPUT_PROTOCOL_PATH) +output_protocol_module = importlib.util.module_from_spec(output_protocol_spec) +sys.modules[OUTPUT_PROTOCOL_NAME] = output_protocol_module +agents_mod = sys.modules["sdk.nexent.core.agents"] +agents_mod.output_protocol = output_protocol_module +assert output_protocol_spec and output_protocol_spec.loader +output_protocol_spec.loader.exec_module(output_protocol_module) + CORE_AGENT_PATH = REPO_ROOT / "sdk" / "nexent" / "core" / "agents" / "core_agent.py" CORE_AGENT_NAME = "sdk.nexent.core.agents.core_agent" sys.modules["sdk.nexent.core"].__path__ = [str(REPO_ROOT / "sdk" / "nexent" / "core")] spec = importlib.util.spec_from_file_location(CORE_AGENT_NAME, CORE_AGENT_PATH) core_agent_module = importlib.util.module_from_spec(spec) sys.modules[CORE_AGENT_NAME] = core_agent_module -agents_mod = sys.modules["sdk.nexent.core.agents"] agents_mod.core_agent = core_agent_module assert spec and spec.loader spec.loader.exec_module(core_agent_module) diff --git a/test/sdk/core/agents/test_guardrail_checkpoints.py b/test/sdk/core/agents/test_guardrail_checkpoints.py index 4d1bd556b3..c7f3afaa5f 100644 --- a/test/sdk/core/agents/test_guardrail_checkpoints.py +++ b/test/sdk/core/agents/test_guardrail_checkpoints.py @@ -10,8 +10,11 @@ from unittest.mock import MagicMock import pytest - -from nexent.core.agents.agent_model import AgentVerificationConfig, GuardrailConfig, GuardrailRule +from nexent.core.agents.agent_model import ( + AgentVerificationConfig, + GuardrailConfig, + GuardrailRule, +) from nexent.core.agents.core_agent import CoreAgent, ToolInputBlockedError from nexent.core.agents.verification import VerificationController @@ -182,7 +185,12 @@ def test_guardrail_wrap_tools_no_engine_is_noop(): # --------------------------------------------------------------------------- import threading as _threading -from nexent.core.agents.core_agent import FinalAnswerError, InvalidActionFormatError + +from nexent.core.agents.output_protocol import ( + ModelOutputProtocolError, + ProtocolErrorReason, + RuntimeFinalAnswer, +) def _make_step_agent(rule, messages, model_output="ok"): @@ -214,6 +222,8 @@ def _make_step_agent(rule, messages, model_output="ok"): agent._last_uncompressed_est = 0 agent._context_tools = MagicMock(return_value=[]) agent._use_structured_outputs_internally = False + agent.output_protocol = "code_action" + agent._consecutive_protocol_errors = 0 agent._ephemeral_system_messages = None agent.verification_controller = controller agent.verification_config = controller.config @@ -237,7 +247,7 @@ def _msg(role, content): def test_step_stream_checkpoint1_terminate(): - """Checkpoint ①: block rule on new_input → terminate → FinalAnswerError with refusal.""" + """Checkpoint ①: blocked input terminates through the trusted runtime path.""" rule = GuardrailRule( name="destructive_rm", pattern=r"(?", "bare answer", "final_answer('42')"): + response = MagicMock() + response.content = content + response.token_usage = None + responses.append(response) + agent.model.side_effect = responses + agent.model.last_finish_reason = "stop" + agent.enable_planning = False + agent.final_answer_checks = None + agent.verification_config = AgentVerificationConfig(enabled=False) + agent.verification_controller.config.step_verification_enabled = False + agent.verification_controller.config.final_verification_enabled = False + agent._finalize_step = MagicMock() + agent._collect_step_metrics = MagicMock() + code_output = MagicMock(output="42", logs="", is_final_answer=True) + agent.python_executor.return_value = code_output + + results = list(agent._run_stream("solve this", max_steps=4)) + + assert agent.model.call_count == 3 + assert len(agent.memory.steps) == 1 + assert agent.memory.steps[0].error is None + assert agent.memory.steps[0].step_number == 1 + assert agent.memory.steps[0].is_final_answer is True + final_model_messages = agent.model.call_args_list[-1].args[0] + repair_text = "\n".join( + str(message.get("content") if isinstance(message, dict) else message.content) + for message in final_model_messages + ) + assert "unsupported_or_tag_only_output" in repair_text + assert "missing_explicit_termination" in repair_text + assert isinstance(results[-1], FinalAnswerStep) + assert results[-1].output == "42" + + def test_step_stream_checkpoint2_mask(): """Checkpoint ②: tool output with keyword → mask → observation redacted.""" rule = GuardrailRule(name="pii", pattern="机密信息", severity="mask") @@ -359,15 +418,34 @@ def test_step_stream_checkpoint2_mask(): action_step = MagicMock() try: next(agent._step_stream(action_step)) - except (FinalAnswerError, StopIteration): + except StopIteration: pass obs = str(action_step.observations) assert "机密信息" not in obs assert "***" in obs +def test_valid_action_resets_consecutive_protocol_errors(): + """Any syntactically valid action resets the consecutive protocol-error counter.""" + rule = GuardrailRule(name="irrelevant", pattern="never-match", severity="block") + agent = _make_step_agent( + rule, + messages=[_msg("user", "hello")], + model_output="print(1)", + ) + agent._consecutive_protocol_errors = 2 + code_output = MagicMock(output="ok", logs="", is_final_answer=False) + agent.python_executor.return_value = code_output + agent.verification_controller.config.step_verification_enabled = False + action_step = MagicMock() + + list(agent._step_stream(action_step)) + + assert agent._consecutive_protocol_errors == 0 + + def test_step_stream_checkpoint3_except_block(): - """Checkpoint ③: pending_refusal + python_executor raises → FinalAnswerError.""" + """Checkpoint ③: a stashed refusal raises a trusted runtime final.""" rule = GuardrailRule( name="destructive_rm", pattern=r"(?", "🙂", "\u200bA"]) +def test_ac_001_visible_content_is_meaningful(value): + assert has_meaningful_visible_content(value) is True + + +@pytest.mark.parametrize( + ("output", "reason"), + [ + ("\u200b\u2060\ufeff", ProtocolErrorReason.EMPTY_VISIBLE_CONTENT), + ("unsupported", ProtocolErrorReason.UNSUPPORTED_OR_TAG_ONLY_OUTPUT), + ("", ProtocolErrorReason.UNSUPPORTED_OR_TAG_ONLY_OUTPUT), + ("plain final answer", ProtocolErrorReason.MISSING_EXPLICIT_TERMINATION), + ("print(1)", ProtocolErrorReason.MALFORMED_ACTION), + ( + "print(1)print(2)", + ProtocolErrorReason.MALFORMED_ACTION, + ), + ("prefixprint(1)", ProtocolErrorReason.MALFORMED_ACTION), + ], +) +def test_ac_001_ac_002_invalid_code_outputs_are_protocol_errors(output, reason): + with pytest.raises(ModelOutputProtocolError) as exc_info: + classify_model_output(output, protocol="code_action") + + assert exc_info.value.reason == reason + + +def test_ac_008_length_finish_reason_is_never_executable_or_final(): + with pytest.raises(ModelOutputProtocolError) as exc_info: + classify_model_output( + "final_answer('partial')", + protocol="code_action", + finish_reason="length", + ) + + assert exc_info.value.reason == ProtocolErrorReason.TRUNCATED_GENERATION + + +def test_ac_003_exact_code_action_is_executable(): + result = classify_model_output( + "\u200b\nfinal_answer('done')\ufeff", + protocol="code_action", + ) + + assert result == ExecutableAction(code="final_answer('done')") + + +def test_ac_004_code_action_preserves_protocol_like_text_inside_final_answer(): + code = '''final_answer("""Markdown: ```python\nprint(1)\n```\nHTML: x\nXML: 值🙂""")''' + + result = classify_model_output( + f"{code}", + protocol="code_action", + ) + + assert result == ExecutableAction(code=code) + + +def test_legacy_run_action_remains_executable_only(): + result = classify_model_output( + "```\nprint('legacy')\n```", + protocol="code_action", + ) + + assert result == ExecutableAction(code="print('legacy')", legacy_format=True) + + +def test_ac_004_final_envelope_preserves_arbitrary_payload(): + payload = "\n# Skill\n```python\nprint('')\n```\n🙂\n" + result = classify_model_output( + f"{payload}", + protocol="final_answer_envelope", + ) + + assert result == ExplicitFinalAnswer(answer=payload) + + +@pytest.mark.parametrize( + "output", + [ + "", + "outsideinside", + "onetwo", + "nested", + "unfinished", + ], +) +def test_ac_010_invalid_final_envelopes_are_rejected(output): + with pytest.raises(ModelOutputProtocolError) as exc_info: + classify_model_output(output, protocol="final_answer_envelope") + + assert exc_info.value.reason == ProtocolErrorReason.INVALID_FINAL_ENVELOPE diff --git a/test/sdk/core/models/test_openai_llm.py b/test/sdk/core/models/test_openai_llm.py index 8aa9cc2473..74510ad8cd 100644 --- a/test/sdk/core/models/test_openai_llm.py +++ b/test/sdk/core/models/test_openai_llm.py @@ -2238,6 +2238,34 @@ def test_call_without_tracker_creates_tracker(openai_model_instance): mock_tracker.record_token.assert_called() +def test_call_can_defer_successful_attempt_commit_for_core_agent(openai_model_instance): + """CoreAgent may validate a successful stream before committing it to clients.""" + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta.content = 'final_answer("ok")' + mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None + mock_chunk.choices[0].delta.reasoning_content = None + mock_chunk.choices[0].finish_reason = "stop" + mock_chunk.usage = MagicMock(prompt_tokens=1, completion_tokens=1) + openai_model_instance.observer.reset_mock() + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.client.chat.completions.create.return_value = [mock_chunk] + result = openai_model_instance( + messages=[{"role": "user", "content": "hello"}], + _token_tracker=MagicMock(), + _defer_attempt_commit=True, + ) + + openai_model_instance.observer.begin_model_attempt.assert_called_once() + openai_model_instance.observer.commit_model_attempt.assert_not_called() + openai_model_instance.observer.rollback_model_attempt.assert_not_called() + assert result.model_attempt_commit_deferred is True + assert isinstance(result.model_attempt_id, str) + assert result.model_attempt_number == 1 + + def test_call_token_estimation_with_list_content(openai_model_instance): """Test __call__ method extracts text from list-formatted content when usage info is None (line 220).""" From e80908ca4cfedb51d6132c8bb92c46cca2bd8993 Mon Sep 17 00:00:00 2001 From: JeffWu <45140512+jeffwu-1999@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:03:42 +0800 Subject: [PATCH 3/5] merge(main): merge v2.6.1 hotfixes from hotfix/v2.6.1 (#3983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Fix(evaluation): run trials in runtime service (#3954) * fix(evaluation): run trials in runtime service Route trial evaluations through the authenticated Config-to-Runtime proxy and use Config's manager only for creation-stage preparation. Keep Agent execution and evaluator scoring in Runtime. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub config thread manager Keep pure-logic service import tests aligned with the Config and Runtime thread-manager split. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub runtime jwt helper * test(evaluation): cover trial proxy error paths * Fix/override delete (#3958) * Fix: override dialog only shows override values, not model defaults (deleted params no longer reappear) * Fix: custom param deletion persists (null markers), per-agent capacity overrides take effect, and edit-dialog connectivity probe uses stored api_key * Fix: rename ModelRequest.model_id to probe_model_id - model_dump() is spread into INSERT column lists, so a model_id field injected an explicit NULL primary key and broke model creation * Fix: move probe_model_id to a dedicated ModelProbeRequest subclass - ModelRequest.model_dump() is spread into INSERT column lists, so any non-column field breaks model creation (Unconsumed column names) * Fix: editing/adding a model no longer steals the occupied default-model slot - persistCustomLocalConfig now only writes the slot when it is empty (onboarding) or the submitted model already occupies it * Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots) * Revert "Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots)" This reverts commit 2376aa50ccb0f170e5412a14c5aee33798ca1f06. * Fix: VLM connectivity probe never found the local test image - the gateway adapter's relative dirname chain resolved two levels short of the package root, so every probe silently fell back to a public URL that is unreachable in offline deployments. Anchor both probe copies on nexent.__file__ so the path survives module moves. --------- Co-authored-by: ljy * cherry-pick: HITL bugfixes from PR #3948 into hotfix/v2.6.1 (#3959) * Bubfix: guarantee event order, harden chunk buffer, SSE-subscribe controller, and break adapter on terminal human_run (#3948) * fix(hitl): preserve correct event ordering between observer chunks and human_interaction requests Root cause: the worker thread writes human_interaction events synchronously via SQLAlchemy in ask_user, while model_output_thinking/parse observer messages flow through the async consumer and are flushed only on a batched threshold (32 chunks or 250ms). When the worker suspends before that flush fires, human_interaction gains a lower event_seq number than the already-buffered observer chunks, causing the SSE replay stream to show them in the wrong order. Fix: replace the plain async-for consumer loop with a manual asyncio.wait iterator using a 50ms timeout. Once the worker finishes producing model output (i.e. right before ask_user), the loop times out and flushes any buffered observer chunks to the DB first, guaranteeing they precede the subsequent human_interaction row. Empty queue idle periods are essentially zero-cost; overall DB write frequency stays on par with the original. * fix(hitl): guarantee observer chunks precede human_interaction in DB event order When the agent invokes ask_user, two independent write paths caused the human_interaction row to be persisted BEFORE model_output_thinking / parse chunks, breaking the SSE replay ordering: the worker thread writes HITL events synchronously via SQLAlchemy, while observer messages flow through the async consumer which only flushes on a batched threshold. Fix: introduce a thread-safe shared chunk buffer on RuntimeInteractionPort (port.add_chunk / port.take_chunks). The async consumer pushes every processed chunk there; the worker thread calls flush_chunks_until_idle() before dispatching any HITL event — it polls the shared buffer and waits for the async loop to drain the observer queue (20ms idle window, 500ms max wait), then persists every chunk in its own transaction. This guarantees chunk event_seq < human_interaction event_seq regardless of async scheduling latency. Also fix ImportError: openai 2.50 removed the httpx2 module. OpenAIModel now falls back from httpx2 to httpx at import time. * test(hitl): cover shared chunk buffer, flush_chunks_until_idle, and httpx2-fallback paths Add unit tests for the RuntimeInteractionPort thread-safe chunk buffer and the flush_chunks_until_idle poll loop that guarantees observer chunks precede human_interaction events in DB order. All five HITL entry points (dispatch / boundary / receipt / finish / _wait_until_ready) are verified to invoke the idle flush before opening their transaction. Also add two tests for the openai_llm httpx2 → httpx ImportError fallback introduced to support openai >= 2.50 where the httpx2 shim was removed: one covers the fallback path, one confirms httpx2 still wins when present. * fix(hitl): address 4 review comments — hard deadline, emit_in_flight, peek_chunks, try/except safety Fix 4 real issues flagged by github-code-review: 1. Non-resettable hard_deadline in flush_chunks_until_idle — previously reset on every drain, meaning a model that kept producing chunks could stall the worker forever. deadline is now computed once at entry and the sleep call clips to hard_deadline - now. 2. _emit_in_flight Event bridges the async emit path and the worker's idle poll. Without this, buffer-empty = 'persisted' was confused with buffer-empty = 'taken for emit but still in run_blocking queue'. The worker now checks both 'buffer empty for settle_ms' AND 'no emit in flight' before deciding the async side is truly idle. 3. peek_chunks() replaces the take-put-back pattern in _flush_if_due. Previously the async loop drained the buffer, decided it was not yet due, then put everything back. That transiently-empty window (16 us normally, arbitrarily long under GIL/GC/preemption) was enough for the worker's 20 ms poll to mis-fire. We now peek (read count, no drain) and only take_chunks when we actually intend to persist. 4. emit_chunks wrapped in try/except that puts drained chunks back into the shared buffer before re-raising, and finish() wraps its flush call in try/except: pass. Guarantees (a) no chunk loss on DB failure and (b) the terminal human_run row is always written even if the flush step fails. Tests added: - 12 pure-mock unit tests in test_runtime_port_chunk_buffer.py cover hard_deadline, _emit_in_flight, peek_chunks, begin_emit/end_emit, try/except path, and every HITL entry-point's flush-before-transaction. - 1 async execute_attempt integration test in new test_application_execute_attempt.py drives the full consumer loop through _flush_if_due (peek → take → begin/end_emit) and the final flush, verifying that every patch line added in application.py is hit. * perf(hitl): stop polling while SSE stream is active, fallback to 5s when disconnected When isRunning=true the EventSource already pushes human_interaction and human_execution events in real time — the 1.5s polling loop duplicated that work, hitting the DB and re-rendering the frontend for every tick. Disable polling entirely while the SSE stream is alive, and drop to 5s intervals only when the stream is closed (e.g. page load before the first run, or after a run finishes) so we can still discover WAITING_HUMAN requests that were created while the client was disconnected. Add isRunning to the useEffect dependency array so the polling cadence resets immediately when the SSE connection state changes. * perf(hitl): replace polling with SSE subscription and move snapshot off the write path Frontend — /conversation polling → /{run_id}/events SSE: - Replace the 5s conversation snapshot polling with a native EventSource subscription to the backend's /{run_id}/events SSE stream. Discovery is now one-shot: conversationId change and the agent stream pause (isRunning true→false), the exact moment a HITL run is most likely to exist. The SSE stream then keeps run state live with native auto-reconnect. - Add dual guards inside refresh() to absorb the thundering herd from adapter.onHumanInteractionEvent (fires once per HITL SSE chunk) plus our own SSE effect: (1) in-flight dedupe — one snapshot absorbs all concurrent callers and returns cached state; (2) 3s minimum interval so bursts after the in-flight resolves do not immediately re-hit DB. - Use a runRef mirror so refresh() stays stable and downstream effects do not re-run on every snapshot. - Detect terminal status inside SSE onmessage and proactively es.close() to prevent EventSource from reconnecting forever against COMPLETED runs. Backend — snapshot off the write path: - Add repository.read_only() context manager: plain SELECT without WITH FOR UPDATE, no transaction, no flush, no _expire scan. Pure reads must not contend with worker writes on the same row lock. - Add service.light_snapshot() using read_only. Retain snapshot() as a writer-path API for any future lock-held callers. - Route conversation_snapshot, run snapshot endpoint, and both snapshot calls inside stream_run() through light_snapshot. - Move expiration to the writer path: decide() still calls _expire inline before processing each request, and expire_waiting() remains the periodic scheduler sweep. Impact: conversation snapshot calls drop from 12+/min (polling) or 10+/s (burst from adapter + SSE) to at most one every 3s. Each call is now two plain SELECTs instead of a lock-held transaction with a possible write from _expire. Read and write paths are fully decoupled. * fix(hitl): detect terminal human_run in adapter and break stream so isRunning flips false After a HITL run reaches FAILED/COMPLETED, Assistant-UI's isRunning stayed true — the stop button remained visible and new messages went into the queue buffer instead of being sent normally. The root cause is that isRunning is driven entirely by the ChatModelRun generator lifetime, which only returns when the backend SSE HTTP connection closes (reader.read() -> done=true). The backend stream_run loop can hang on heartbeat even after the run is terminal when the SSE was opened during WAITING_HUMAN with attempt_active=true: the break condition requires both cursor >= event_seq AND (terminal status OR WAITING_HUMAN with attempt_active=false and empty rows). If continueHitl fires mid-flight with a stale after_event, the cursor never catches up, so the SSE stays alive forever and the generator never returns. Stop depending on the backend closing first. Inside the adapter's SSE chunk loop, detect a terminal human_run event (status in COMPLETED, FAILED, STOPPED, EXPIRED), set a hitlTerminal flag, break the inner for-loop, and let the outer while-loop exit via the same flag on the next iteration. Assistant-UI sees the generator return and flips isRunning false immediately. Only affects HITL streams — the normal non-HITL agent path never emits human_run events so this branch is never taken. * test(hitl): update mock from snapshot to light_snapshot after read-path refactor test_human_interaction_app.py still mocked service.snapshot after commit 288ae4e69 moved conversation_snapshot and the run snapshot endpoint to service.light_snapshot (read-only path, no lock, no _expire). The fixture return_value and the two assert_called_once_with/assert_not_called assertions all referenced the old method name, causing CI to fail because MagicMock.snapshot was never called. * test(hitl): raise diff coverage above the 90% merge gate Codecov reported 70.43% patch coverage (target 90%) because new error and race paths in the HITL changes had no tests. Add mocked unit tests for: leftover chunk flush in execute_attempt's finally block before the failed finish, CancelledError scope and stop-event fallbacks, RunTerminated finish race, recovery-required outcome, and chunk iterator aclose failure tolerance; runtime_port in-flight emit busy detection, chunk restoration when emit_chunks raises, and terminal status persistence on flush failure; light_snapshot/read_only service behavior with signed tenant and user scoping; and the httpx fallback when openai._base_client.httpx2 is absent. Measured locally with CI-equivalent per-file pytest isolation: patch coverage 202/202 = 100%. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * refactor(hitl-test): dedupe fake port setup to satisfy SonarCloud duplication gate SonarCloud failed the quality gate with new_duplicated_lines_density=5.2% (threshold 3%), caused solely by test_application_execute_attempt.py: the inline _Port stub in the flush test and the one in _run_execute_attempt duplicated ~69 lines (2 CPD blocks, 14.4% file density). Extract a shared _build_port_class/_make_port_factory plus a _patched_application context manager and _execute_attempt_args so both call sites reuse a single definition; drop dead code (last_flush, install/monkeypatches, unused imports) and fix the latent bare-contextmanager NameError by using contextlib.contextmanager. No behavioral change; all 8 tests pass. * fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm openai >= 2.50 removed the httpx2 shim from openai._base_client. The bare import httpx2 causes ImportError in CI and on systems with recent openai versions. This restores the try/except fallback introduced in PR #3948 commit 9521b934 and later accidentally reverted by commit d086da259. * Revert "fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm" This reverts commit 8e06ce348dfc8c34baf42c6bfa8211883d0a2c61. * 🐛 Bugfix: Fixed an issue where the sandbox container user lacked the permissions to create folders and files. (#3963) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngi… (#3962) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngineProvider - get_provider_models routed every provider through the OpenAI-compatible adapter, so ModelEngine batch import failed (wrong endpoint path /open/router/v1/models, self-signed cert, custom type taxonomy, missing per-model base_url). The dedicated class existed but was never wired in. * chore: ModelEngine catalog base_url placeholder - preset public URL is wrong for private deployments, placeholder communicates the required /open/router/v1 path format --------- Co-authored-by: ljy * [codex] fix(agent): silently retry transient model errors (#3965) * fix(agent): retry transient model failures silently * fix(model): support OpenAI httpx2 timeout client * test(model): add deterministic OpenAI-compatible mock * fix(agent): keep stream runtime within line budget * Fix: AIDP knowledge base bug fix (#3967) * Fix: editing a ModelEngine model no longer flips ssl_verify to True - the update path only checked api_key emptiness while the create path also exempts open/router URLs (ModelEngine self-signed certs). The edit dialog prefills the real key and always submits it, so any edit silently broke connectivity. Exemption now checks the payload URL with a fallback to the stored record; batch-edit groups get the same protection * refactor: extract MODEL_ENGINE_URL_MARKER constant (SonarCloud S1192) and use a placeholder domain in test fixtures - no behavior change * [fix] enforce explicit CodeAgent termination and silent recovery (#3969) * fix(agent): enforce explicit CodeAgent termination * fix(test): restore CodeAgent CI compatibility * cherry-pick: HITL reliability fixes from PR #3977 into hotfix/v2.6.1 (#3981) * Fix StopAsyncIteration leak in execute_attempt finally block Root cause: when the agent chunk stream exhausted normally, the finally block awaited the already-consumed anext_task, re-raising StopAsyncIteration which was not suppressed by the existing CancelledError handling. The leftover chunk flush was skipped, successful runs were marked as failed, and the claiming scheduler job logged errors. Fix: reset anext_task to None before breaking out of the consumption loop so the finally block skips the await and always reaches the leftover flush and terminal finish() write. Tightened the regression test to assert that a normally exhausted stream does not leak StopAsyncIteration and that finish() is called. Also deduplicated the two _Port stub classes via a shared factory to satisfy the SonarCloud new_duplicated_lines_density gate. * Fix HITL form not appearing until page refresh Root cause: the frontend discovery chain rate-limited every refresh() with a 3s min interval and in-flight coalescing, silently dropping the critical human_run/human_interaction events that follow an ask_user suspension. The run event stream goes quiet afterwards, so nothing re-triggered the snapshot and the form only appeared after a manual page reload. Fix: refresh() now takes a force flag that bypasses the throttle; force callers arriving while a snapshot is in flight are re-run via a trailing refreshRef invocation instead of being dropped. SSE human_run/human_interaction/human_decision/human_execution messages and the chat-adapter onHumanInteractionEvent callback now force refresh. * Fix SSE chunk/HITL event ordering race under real server load Root cause: chunk persistence and HITL event writes ran in two threads (async consumer via run_blocking on the control-io lane, worker thread synchronously) with seq assigned at DB row-lock acquisition time. Two race windows reordered messages on loaded servers but never locally: (1) flush_chunks_until_idle's 500ms hard deadline fired while the async drain was still in flight, so the HITL row committed before chunks produced earlier (form appearing before model output); (2) worker emit_chunks and the async _flush_if_due drained concurrently without mutual exclusion, so seq order followed lock acquisition instead of production order. Fix: replace the begin_emit/end_emit Event with a shared threading.Lock and move take_chunks+emit_chunks into one atomic critical section (drain_and_emit) used by both the async consumer and the worker flush. The hard deadline may now only fire once the lock is free, guaranteeing in-flight drains commit before the caller writes its HITL transaction. Added regressions: flush waiting for an in-flight drain past its deadline, and concurrent drains preserving chunk production order. * fix(hitl): recover pending form when SSE delivery stalls silently Root cause: form discovery relied solely on a single EventSource plus refresh() with no fallback. A half-open connection (e.g. hung dev proxy) never raises an error event or reconnects, so human_interaction events are lost until a manual page refresh. A hung snapshot fetch could also keep refreshInFlight stuck forever, silently dropping every later refresh, including forced ones. Changes: - Poll the read-only snapshot every 5s while a run is active; the tick shares the refresh throttle and in-flight guard, so it adds no load while SSE delivery is healthy and discovers a pending form within 5s when the stream stalls - Add a 15s AbortSignal timeout to human-interaction client requests so a hung fetch releases the in-flight guard instead of bricking it - Wrap the human_run chunk JSON.parse in the chat adapter with try/catch: the stream loop has a finally but no catch, so a malformed payload would silently kill the whole chat stream read loop * fix(hitl): stop parked human-input waits from consuming scheduler slots Root cause: while a run waits for a human decision, its executor task parks inside _wait_until_ready and the lease renewal loop keeps the lease alive, so the run occupies one HITL_MAX_CONCURRENCY slot for up to HITL_WAIT_SECONDS (default 24h). With HITL enabled every non-debug chat is dispatched through this scheduler, so two unattended forms filled the default concurrency of 2 and froze all conversations: new agent/run streams only emitted heartbeats because READY runs were never claimed. Fix: add LeaseScheduler.mark_waiting so executors can flag themselves as parked on external input. Slot capacity is now max_concurrency minus executing jobs only (running minus waiting), and the waiting flag is cleared in the job's finally block. RuntimeInteractionPort relays enter/exit of _wait_until_ready through a wait_reporter callback, covering resume, termination and lease loss paths. The reporter degrades safely: a stale SDK copy without mark_waiting falls back to slot-consuming waits, and call_soon_threadsafe is wrapped in a lambda because it does not forward keyword arguments. Config: raise the env example defaults from 24h to 1h waits and concurrency 2 to 100, since waiting runs no longer consume execution slots. Tests: new test_waiting_jobs_do_not_consume_concurrency; scheduler suite 14/14, HITL service 12 passed, runtime and app suites 43/43. * test(scheduler): fix flaky waiting-concurrency assertion on fast event loops Root cause: job 2's executor completed instantly after appending to started, so its done-callback could discard it from _running before the active_count == 2 assertion ran. On Linux CI the event loop schedules that callback first, making the test fail intermittently. Fix: both executors now park on the shared gate via separate running events, so the assertion observes a stable running set instead of a transient window. * Fix: drop unrelated AIDP interface refactor from the AIDP knowledge base fix (#3980) The AIDP knowledge base fix reached hotfix/v2.6.1 through PR #3967, which also carried two unrelated upstream changes that this release branch never had: - #3909 Knowledge base interface optimization (AIDP UI refactor) - #3930 support AIDP knowledge file deletion and download Both are removed here so the release line keeps only the bug fix. - Restore the AIDP frontend components to their pre-refactor layout and drop the helper modules only the refactor used: AidpKnowledgeBaseModalParts, useAidpGroupOptions, aidpUploadUtils. - Drop the #3930 document remove/download endpoints from services/api.ts and the AIDP translations that only those screens referenced. - Keep the fix itself unchanged: knowledge-base scoped Channels and KnowledgeFiles/History paths, all-status document listing, keyword search, status labels (UPLOADING / PROCESSING / EXTRACTING) and the upload-triggered polling. Verified: - pytest test/ext_components/aidp -q -> 583 passed - frontend `npm run type-check` (tsc --noEmit) -> no errors --------- Co-authored-by: cj2026-bit <647646783@qq.com> Co-authored-by: lijiayang619 <1170349871@qq.com> Co-authored-by: ljy Co-authored-by: bernard1234 <840646206@qq.com> Co-authored-by: panyehong <91180085+YehongPan@users.noreply.github.com> Co-authored-by: Jason Wang <56037774+JasonW404@users.noreply.github.com> Co-authored-by: gs-aion Co-authored-by: chase --- .../ext_components/aidp/apps/aidp_mgmt_app.py | 108 +- .../aidp/services/aidp_service.py | 140 +-- .../services/human_interaction/application.py | 66 +- .../human_interaction/runtime_port.py | 144 ++- deploy/env/.env.example | 4 +- deploy/env/hitl.env.example | 4 +- .../adapter/remote-chat-model-adapter.ts | 17 +- frontend/app/[locale]/newchat/page.tsx | 4 +- .../aidp/components/AidpCreateKbModal.tsx | 1003 ++++++++++------- .../aidp/components/AidpDocumentList.tsx | 487 ++++---- .../AidpKnowledgeBaseModalParts.tsx | 162 --- .../components/AidpKnowledgeConfiguration.tsx | 54 +- .../aidp/components/AidpKnowledgeList.tsx | 509 ++++----- .../aidp/components/AidpUpdateKbModal.tsx | 201 ++-- .../aidp/hooks/useAidpGroupOptions.ts | 30 - .../aidp/services/aidpKnowledgeService.ts | 137 +-- .../aidp/services/aidpUploadUtils.ts | 15 - frontend/features/humanInteraction/client.ts | 6 + .../useHumanInteractionController.ts | 73 +- frontend/public/locales/en/common.json | 47 +- frontend/public/locales/zh/common.json | 47 +- frontend/services/api.ts | 4 - sdk/nexent/scheduler/core.py | 25 +- .../test_application_execute_attempt.py | 27 +- .../test_runtime_port_chunk_buffer.py | 124 +- .../mock_servers/aidp_mgmt_mock_server.py | 164 +-- .../ext_components/aidp/test_aidp_mgmt_app.py | 155 +-- test/ext_components/aidp/test_aidp_service.py | 156 +-- test/sdk/scheduler/test_core.py | 39 + 29 files changed, 1592 insertions(+), 2360 deletions(-) delete mode 100644 frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx delete mode 100644 frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts delete mode 100644 frontend/ext_components/aidp/services/aidpUploadUtils.ts diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 5fa9fe224b..23e7684198 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -18,25 +18,27 @@ import time from http import HTTPStatus from typing import Annotated, List, Optional -from uuid import UUID -from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile -from fastapi.responses import JSONResponse, StreamingResponse -from nexent.core.concurrency import run_blocking +from fastapi import APIRouter, File, Path, Query, Request, UploadFile +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError -from starlette.background import BackgroundTask +from nexent.core.concurrency import run_blocking from consts.const import AIDP_API_KEY, AIDP_SERVER_URL from consts.error_code import ErrorCode from consts.exceptions import AppException, UnauthorizedError from database.user_tenant_db import get_user_role_by_tenant from ext_components.aidp.consts.aidp_exceptions import ( - AidpGroupValidationError, AidpKbConflictError, + AidpKbNotFoundError, + AidpKbPermissionDeniedError, + AidpKbSyncError, + AidpGroupValidationError, ) from ext_components.aidp.database import aidp_permission_db from ext_components.aidp.services import aidp_permission_service as perms +from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings from ext_components.aidp.services.aidp_access_service import ( get_cached_aidp_channels, get_cached_aidp_doc_count, @@ -46,13 +48,6 @@ invalidate_aidp_kb_detail_cache, resolve_current_aidp_access, ) -from ext_components.aidp.services.aidp_kb_update_service import save_kb_settings -from ext_components.aidp.services.aidp_permission_service import ( - EDIT, - PRIVATE, - READ_ONLY, - _validate_group_ids_strict, # noqa: F401 - retained as a module-level compatibility symbol -) from ext_components.aidp.services.aidp_service import ( _timestamp_to_iso, count_aidp_docs_impl, @@ -63,15 +58,18 @@ list_aidp_doc_history_impl, list_aidp_docs_impl, list_aidp_models_impl, - remove_aidp_docs_impl, - stream_aidp_doc_impl, select_aidp_channel, update_aidp_kb_impl, upload_aidp_docs_impl, ) +from ext_components.aidp.services.aidp_permission_service import ( + EDIT, + PRIVATE, + READ_ONLY, + _validate_group_ids_strict, +) from utils import auth_utils as auth_utils_module - aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -201,17 +199,6 @@ class SetPermissionRequest(BaseModel): ) -class RemoveAidpDocumentsRequest(BaseModel): - - file_uuids: List[UUID] = Field(..., min_length=1, description="AIDP file UUIDs") - - -class DownloadAidpDocumentRequest(BaseModel): - """AIDP file selected for download.""" - - file_uuid: UUID = Field(..., description="AIDP file UUID") - - # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -268,6 +255,11 @@ def _raise_aidp_conflict(exc: IntegrityError) -> None: ) +# HTTPException is imported lazily to keep FastAPI's exception handler in +# control of the response body. +from fastapi import HTTPException # noqa: E402 (placed here to avoid editing mid-file) + + def _credentials() -> tuple[str, str]: return AIDP_SERVER_URL, AIDP_API_KEY @@ -549,9 +541,6 @@ async def list_knowledge_bases( server_url, api_key = _credentials() normalized_keyword = (keyword or "").strip() or None started_at = time.perf_counter() - # ``run_blocking`` (not a bare to_thread) keeps AIDP catalog reads on the - # managed control-io lane, and the keyword is still forwarded so the remote - # catalog is narrowed server-side before the permission intersection. rows = await run_blocking( "aidp-accessible-rows", _current_accessible_rows, @@ -1029,65 +1018,6 @@ async def list_documents( return JSONResponse(status_code=HTTPStatus.OK, content=result) -@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/remove") -async def remove_documents( - request: Request, - kds_id: Annotated[str, Path(description="Knowledge base ID")], - body: RemoveAidpDocumentsRequest, -) -> JSONResponse: - """Remove AIDP documents.""" - user_id, tenant_id = await _auth(request) - perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") - - server_url, api_key = _credentials() - result = await run_blocking( - "aidp-remove-documents", - remove_aidp_docs_impl, - server_url, - api_key, - kds_id, - [str(file_uuid) for file_uuid in body.file_uuids], - lane="control-io", - owner="config", - ) - - success_list = result["success_list"] - if success_list: - invalidate_aidp_kb_detail_cache(server_url, api_key, kds_id) - invalidate_aidp_doc_count_cache(server_url, api_key, kds_id) - return JSONResponse(status_code=HTTPStatus.OK, content=result) - - -@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/documents/download") -async def download_document( - request: Request, - kds_id: Annotated[str, Path(description="Knowledge base ID")], - body: DownloadAidpDocumentRequest, -) -> StreamingResponse: - """Proxy an AIDP document as a binary attachment.""" - user_id, tenant_id = await _auth(request) - perms.require_permission(kds_id, user_id, tenant_id, required="READ") - - server_url, api_key = _credentials() - aidp_response = await stream_aidp_doc_impl( - server_url, - api_key, - kds_id, - str(body.file_uuid), - ) - response_headers = { - "Content-Disposition": aidp_response.headers["Content-Disposition"], - "X-File-Size": aidp_response.headers["X-File-Size"], - } - - return StreamingResponse( - aidp_response.aiter_bytes(), - media_type=aidp_response.headers["Content-Type"], - headers=response_headers, - background=BackgroundTask(aidp_response.aclose), - ) - - @aidp_mgmt_router.patch("/aidp-permissions/{kds_id}") async def set_permission( request: Request, diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index 50400aee42..4116e5feea 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -5,16 +5,15 @@ import logging import time from datetime import datetime, timezone -from typing import Any, Callable, Dict, List, NoReturn +from typing import Any, Callable, Dict, List from urllib.parse import quote, urljoin import httpx -from nexent.utils.http_client_manager import http_client_manager from consts.const import AIDP_TENANT_ID from consts.error_code import ErrorCode from consts.exceptions import AppException - +from nexent.utils.http_client_manager import http_client_manager logger = logging.getLogger("aidp_service") @@ -67,39 +66,6 @@ def _extract_upstream_error(response: httpx.Response) -> str | None: return reason[:_MAX_UPSTREAM_ERROR_REASON_LENGTH] -def _raise_aidp_http_error(error: httpx.HTTPStatusError, operation: str) -> NoReturn: - """Map an AIDP HTTP error to the common application exception format.""" - response = error.response - upstream_reason = _extract_upstream_error(response) - logger.exception( - "AIDP %s HTTP error: status_code=%s upstream_reason=%s", - operation, - response.status_code, - upstream_reason or "unavailable", - ) - details = { - "upstream_status": response.status_code, - "upstream_reason": upstream_reason, - } - error_code = { - 401: ErrorCode.AIDP_AUTH_ERROR, - 403: ErrorCode.AIDP_AUTH_ERROR, - 429: ErrorCode.AIDP_RATE_LIMIT, - }.get(response.status_code, ErrorCode.AIDP_SERVICE_ERROR) - fallback_message = { - ErrorCode.AIDP_AUTH_ERROR: f"AIDP authentication failed: {str(error)}", - ErrorCode.AIDP_RATE_LIMIT: f"AIDP rate limit exceeded: {str(error)}", - }.get( - error_code, - f"AIDP API HTTP error {response.status_code}: {str(error)}", - ) - raise AppException( - error_code, - upstream_reason or fallback_message, - details=details, - ) - - def _extract_upload_failures(response: httpx.Response) -> List[Dict[str, str]]: """Extract per-file upload failures from AIDP's structured error body.""" try: @@ -364,7 +330,6 @@ def _validate_params(server_url: str, api_key: str) -> str: _AIDP_RETRY_BACKOFF_FACTOR = 0.5 _AIDP_RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504} _AIDP_READ_TIMEOUT_SECONDS = 30.0 -_AIDP_DOWNLOAD_TIMEOUT_SECONDS = 120.0 def _request_with_retry( @@ -1249,107 +1214,6 @@ def upload_aidp_docs_impl( ) -def remove_aidp_docs_impl( - server_url: str, - api_key: str, - kds_id: str, - file_uuids: List[str], -) -> Dict[str, Any]: - """Remove one or more documents from an AIDP knowledge base.""" - normalized_url = _validate_params(server_url, api_key) - - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - remove_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Remove" - remove_url = urljoin(f"{normalized_url}/", remove_path) - logger.info("Removing %d AIDP documents from %s", len(file_uuids), remove_url) - - try: - client = http_client_manager.get_sync_client( - base_url=normalized_url, - timeout=_AIDP_READ_TIMEOUT_SECONDS, - verify_ssl=False, - ) - response = _request_with_retry( - lambda: client.post( - remove_url, - headers=headers, - json={"file_uuids": file_uuids}, - ), - context=f"remove-docs:{kds_id}", - ) - response.raise_for_status() - result = response.json() - return result - except httpx.RequestError as e: - logger.exception("AIDP document removal request failed: %s", e) - raise AppException( - ErrorCode.AIDP_CONNECTION_ERROR, - f"AIDP API request failed: {str(e)}", - ) - except httpx.HTTPStatusError as e: - _raise_aidp_http_error(e, "document removal") - except ValueError as e: - logger.exception("Failed to parse AIDP document removal response: %s", e) - raise AppException( - ErrorCode.AIDP_RESPONSE_ERROR, - f"Failed to parse AIDP API response: {str(e)}", - ) - - -async def stream_aidp_doc_impl( - server_url: str, - api_key: str, - kds_id: str, - file_uuid: str, -) -> httpx.Response: - """Open a streaming response for one AIDP document.""" - normalized_url = _validate_params(server_url, api_key) - - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - download_path = f"{_get_list_path()}/{kds_id}/KnowledgeFiles/Download" - download_url = urljoin(f"{normalized_url}/", download_path) - logger.info("Downloading AIDP document %s from %s", file_uuid, download_url) - - response: httpx.Response | None = None - try: - client = http_client_manager.get_async_client( - base_url=normalized_url, - timeout=_AIDP_DOWNLOAD_TIMEOUT_SECONDS, - verify_ssl=False, - ) - response = await client.send( - client.build_request( - "POST", - download_url, - headers=headers, - json={"file_uuid": file_uuid}, - ), - stream=True, - ) - if response.status_code >= 400: - await response.aread() - response.raise_for_status() - return response - except httpx.RequestError as e: - if response is not None: - await response.aclose() - logger.exception("AIDP document download request failed: %s", e) - raise AppException( - ErrorCode.AIDP_CONNECTION_ERROR, - f"AIDP API request failed: {str(e)}", - ) - except httpx.HTTPStatusError as e: - if response is not None: - await response.aclose() - _raise_aidp_http_error(e, "document download") - - def count_aidp_docs_impl(server_url: str, api_key: str, kds_id: str) -> int: """Get total document count in a KB via AIDP POST .../Count endpoint. diff --git a/backend/services/human_interaction/application.py b/backend/services/human_interaction/application.py index d2251fa322..bf84b5e710 100644 --- a/backend/services/human_interaction/application.py +++ b/backend/services/human_interaction/application.py @@ -112,7 +112,24 @@ def authorize(): except Exception as exc: raise RunTerminated("Run authorization could not be revalidated") from exc - port = RuntimeInteractionPort(service, identity, lease.owner_id, authorize, live_resume=True) + def report_waiting(waiting: bool) -> None: + # Worker threads park inside _wait_until_ready; relay the state to + # the scheduler loop so human waits do not consume execution slots. + # The reporter is a pure concurrency-budget optimization: a dead + # loop (service shutdown) or a stale SDK copy without mark_waiting + # must degrade to slot-consuming waits, never break execution. + mark_waiting = getattr(human_run_scheduler, "mark_waiting", None) + if mark_waiting is None: + return + # call_soon_threadsafe takes no kwargs: wrap the keyword-only flag. + try: + loop.call_soon_threadsafe(lambda: mark_waiting(lease.job_id, waiting=waiting)) + except RuntimeError: + pass + + port = RuntimeInteractionPort( + service, identity, lease.owner_id, authorize, live_resume=True, wait_reporter=report_waiting, + ) saved = port.request_payload if saved.get("runtime_mode") == "native-live-v1" and port.checkpoint: raise RecoveryRequired("The original native execution is no longer available") @@ -173,24 +190,21 @@ async def _flush_if_due() -> None: """Flush buffered chunks on the interval/batch trigger to keep DB order. The async loop flushes on its own; the worker flushes again before - each HITL transaction. Peek first and never take-put-back — that - would open a transiently empty window the worker's idle poll can - mistake for "async is done". + each HITL transaction. """ nonlocal last_flush - if (time.monotonic() - last_flush >= _FLUSH_INTERVAL - or port.peek_chunks() >= _FLUSH_BATCH): - buffered = port.take_chunks() - if buffered: - try: - port.begin_emit() - await run_blocking( - "hitl-port-emit_chunks", port.emit_chunks, buffered, - lane="control-io", owner=__name__, - ) - last_flush = time.monotonic() - finally: - port.end_emit() + if ((time.monotonic() - last_flush >= _FLUSH_INTERVAL + or port.peek_chunks() >= _FLUSH_BATCH) + and port.peek_chunks()): + # drain_and_emit runs take_chunks + emit_chunks in one + # emit-lock critical section on the lane thread, so it cannot + # interleave with the worker's flush — seq order stays + # production order. + await run_blocking( + "hitl-port-drain_and_emit", port.drain_and_emit, + lane="control-io", owner=__name__, + ) + last_flush = time.monotonic() chunk_iter = _stream_agent_chunks( agent_request=request, user_id=identity["user_id"], tenant_id=identity["tenant_id"], @@ -216,6 +230,10 @@ async def _flush_if_due() -> None: try: chunk = task.result() except StopAsyncIteration: + # Stream exhausted: drop the consumed task so the finally + # block does not re-raise its StopAsyncIteration and skip + # the leftover flush plus the terminal finish() write. + anext_task = None break port.add_chunk(chunk) await _flush_if_due() @@ -230,16 +248,10 @@ async def _flush_if_due() -> None: except Exception: pass # Final flush so leftover chunks precede finish() in DB order. - leftover = port.take_chunks() - if leftover: - try: - port.begin_emit() - await run_blocking( - "hitl-port-emit_chunks", port.emit_chunks, leftover, - lane="control-io", owner=__name__, - ) - finally: - port.end_emit() + await run_blocking( + "hitl-port-drain_and_emit", port.drain_and_emit, + lane="control-io", owner=__name__, + ) await run_blocking( "hitl-port-finish", port.finish, run_info.attempt_outcome or "failed", lane="control-io", owner=__name__, diff --git a/backend/services/human_interaction/runtime_port.py b/backend/services/human_interaction/runtime_port.py index 62b01b417e..d0345fb547 100644 --- a/backend/services/human_interaction/runtime_port.py +++ b/backend/services/human_interaction/runtime_port.py @@ -17,14 +17,18 @@ class RuntimeInteractionPort: - def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, live_resume=False, stop_event=None): + def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, live_resume=False, stop_event=None, + wait_reporter=None): """Bind the port and set up async-worker chunk coordination. ``_chunk_buffer`` stages processed observer chunks: the async consumer appends via add_chunk and the worker flushes before each HITL event so chunk rows precede human_interaction rows in DB event order. - ``_emit_in_flight`` marks an in-flight hand-off to the DB thread so an - empty buffer is not mistaken for "async is idle". + ``_emit_lock`` serializes every take_chunks + emit_chunks critical + section (async drain_and_emit and the worker's own flush), so a held + lock also means "a drain is in flight and its chunks are uncommitted". + ``wait_reporter`` relays enter/exit of human-input waits to the + scheduler so parked runs stop consuming execution concurrency slots. """ self.service = service self.repository = service.repository @@ -38,9 +42,10 @@ def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, self.allowed_tools = frozenset(allowed_tools) self.live_resume = live_resume self.stop_event = stop_event + self.wait_reporter = wait_reporter self._chunk_buffer: list[str] = [] self._chunk_buffer_lock = threading.Lock() - self._emit_in_flight = threading.Event() + self._emit_lock = threading.Lock() with self.transaction() as tx: self.checkpoint = self.cipher.open(tx.run.checkpoint) self.request_payload = self.cipher.open(tx.run.request_payload) @@ -62,48 +67,59 @@ def peek_chunks(self) -> int: with self._chunk_buffer_lock: return len(self._chunk_buffer) - def begin_emit(self) -> None: - """Signal that the async consumer is handing chunks to run_blocking(emit_chunks).""" - self._emit_in_flight.set() + def drain_and_emit(self) -> None: + """Atomically drain and persist buffered chunks. Lane-thread only. - def end_emit(self) -> None: - """Signal that the async consumer's run_blocking(emit_chunks) has returned.""" - self._emit_in_flight.clear() + take_chunks and emit_chunks share one critical section under the emit + lock so a concurrent flush can never interleave DB seq assignment — + the seq order then always matches the chunk production order. + """ + with self._emit_lock: + chunks = self.take_chunks() + if not chunks: + return + try: + self.emit_chunks(chunks) + except Exception: + # Never lose drained chunks; put them back for retry. + for chunk in chunks: + self.add_chunk(chunk) + raise def flush_chunks_until_idle(self, *, max_wait_ms: int = 500, settle_ms: int = 20) -> None: """Wait for the async consumer to drain the observer queue, then persist. - Worker-thread only. Returns once the buffer has stayed empty for - ``settle_ms`` with no in-flight emit; ``max_wait_ms`` is a hard - upper bound measured from entry and never resets. + Worker-thread only. take_chunks and emit_chunks run inside the same + emit-lock critical section as the async drain_and_emit, so concurrent + flushes cannot reorder seq assignment. ``max_wait_ms`` bounds how long + we wait for NEW chunks — it must NOT fire while another drain holds + the lock, because its chunks are produced before this HITL event but + still uncommitted; returning early would order HITL rows ahead of + them. The deadline may therefore only fire once the lock is free. """ hard_deadline = time.monotonic() + max_wait_ms / 1000.0 idle_since: float | None = None while True: now = time.monotonic() - if now >= hard_deadline: + if now >= hard_deadline and not self._emit_lock.locked(): break - - chunks = self.take_chunks() - if chunks: - try: - self.emit_chunks(chunks) - except Exception: - # Never lose drained chunks; put them back for retry. - for chunk in chunks: - self.add_chunk(chunk) - raise - idle_since = None - elif self._emit_in_flight.is_set(): - # Empty buffer is not idle while an emit is in flight. - idle_since = None - elif idle_since is None: - idle_since = now - elif now - idle_since >= settle_ms / 1000.0: - break - - time.sleep(min(settle_ms / 1000.0, hard_deadline - now)) + with self._emit_lock: + chunks = self.take_chunks() + if chunks: + try: + self.emit_chunks(chunks) + except Exception: + # Never lose drained chunks; put them back for retry. + for chunk in chunks: + self.add_chunk(chunk) + raise + idle_since = None + elif idle_since is None: + idle_since = now + elif now - idle_since >= settle_ms / 1000.0: + break + time.sleep(min(settle_ms / 1000.0, max(hard_deadline - now, 0.0))) @contextmanager def transaction(self, *, receipt=False): @@ -199,33 +215,39 @@ def close_steering(self, checkpoint): def _wait_until_ready(self): """Park this worker while retaining the current Python continuation.""" - next_authorization_check = 0.0 - while True: - if self.stop_event is not None and self.stop_event.is_set(): - raise RunTerminated("The managed execution was cancelled") - now = time.monotonic() - if now >= next_authorization_check: - self.authorize() - next_authorization_check = now + 5.0 - with self.repository.transaction(self.run_id, self.tenant_id, self.user_id) as tx: - if (tx is None or tx.run.fence != self.fence or tx.run.lock_owner != self.owner_id - or tx.run.lock_until is None or tx.run.lock_until <= utcnow()): - raise RunTerminated("Execution lease is no longer valid") - self.service._expire(tx) - if tx.run.status == "READY": - # Flush so lingering chunks precede the human_run row. - self.flush_chunks_until_idle() - tx.run.status = "RUNNING" - tx.emit({"type": "human_run", "content": { - "run_id": self.run_id, "status": "RUNNING", - }}) - return - if tx.run.status != "WAITING_HUMAN": - raise RunTerminated("Run no longer permits live continuation") - if self.stop_event is not None: - self.stop_event.wait(0.2) - else: - time.sleep(0.2) + if self.wait_reporter is not None: + self.wait_reporter(True) + try: + next_authorization_check = 0.0 + while True: + if self.stop_event is not None and self.stop_event.is_set(): + raise RunTerminated("The managed execution was cancelled") + now = time.monotonic() + if now >= next_authorization_check: + self.authorize() + next_authorization_check = now + 5.0 + with self.repository.transaction(self.run_id, self.tenant_id, self.user_id) as tx: + if (tx is None or tx.run.fence != self.fence or tx.run.lock_owner != self.owner_id + or tx.run.lock_until is None or tx.run.lock_until <= utcnow()): + raise RunTerminated("Execution lease is no longer valid") + self.service._expire(tx) + if tx.run.status == "READY": + # Flush so lingering chunks precede the human_run row. + self.flush_chunks_until_idle() + tx.run.status = "RUNNING" + tx.emit({"type": "human_run", "content": { + "run_id": self.run_id, "status": "RUNNING", + }}) + return + if tx.run.status != "WAITING_HUMAN": + raise RunTerminated("Run no longer permits live continuation") + if self.stop_event is not None: + self.stop_event.wait(0.2) + else: + time.sleep(0.2) + finally: + if self.wait_reporter is not None: + self.wait_reporter(False) def dispatch(self, slot, tool, arguments, *, interaction=None): self.authorize() diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 7c3b478fed..58f7d817e7 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -360,5 +360,5 @@ HITL_ACCEPT_NEW_RUNS=true # non-built-in Agent tool requires explicit review before execution. HITL_TOOL_APPROVAL_ENABLED=false HITL_ENCRYPTION_KEY= -HITL_WAIT_SECONDS=86400 -HITL_MAX_CONCURRENCY=2 +HITL_WAIT_SECONDS=3600 +HITL_MAX_CONCURRENCY=100 diff --git a/deploy/env/hitl.env.example b/deploy/env/hitl.env.example index 1f4d8b2d62..c72e584639 100644 --- a/deploy/env/hitl.env.example +++ b/deploy/env/hitl.env.example @@ -4,5 +4,5 @@ HITL_ENABLED=true HITL_ACCEPT_NEW_RUNS=true HITL_TOOL_APPROVAL_ENABLED=false HITL_ENCRYPTION_KEY= -HITL_WAIT_SECONDS=86400 -HITL_MAX_CONCURRENCY=2 +HITL_WAIT_SECONDS=3600 +HITL_MAX_CONCURRENCY=100 diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index f394ec5a08..ad973bad32 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -2191,10 +2191,21 @@ export const remoteChatModelAdapter: ChatModelAdapter = { } if (chunk.type === "human_run") { - const value = - typeof chunk.content === "string" + // The stream loop below has a finally but no catch: a malformed + // payload must not kill the whole chat stream. Skip the chunk and + // let the HITL controller snapshot/polling recover the state. + let value: Record; + try { + value = (typeof chunk.content === "string" ? JSON.parse(chunk.content) - : chunk.content; + : chunk.content) as Record; + } catch (error) { + log.warn( + "[ChatModelAdapter] Failed to parse human_run chunk:", + error + ); + continue; + } if (value && typeof value.run_id === "string") humanRunId = value.run_id; custom?.onHumanInteractionEvent?.(); diff --git a/frontend/app/[locale]/newchat/page.tsx b/frontend/app/[locale]/newchat/page.tsx index 31f40986e3..0ac749d134 100644 --- a/frontend/app/[locale]/newchat/page.tsx +++ b/frontend/app/[locale]/newchat/page.tsx @@ -583,7 +583,9 @@ const HomeContent: FC<{ onRuntimeMetadataSent: handleRuntimeMetadataSent, onKnowledgeScopeResolved: handleKnowledgeScopeResolved, onGenerationStopped: handleGenerationStopped, - onHumanInteractionEvent: hitlController.refresh, + // HITL events (ask_user suspend, run transitions) must bypass the + // snapshot throttle: the event stream goes quiet afterwards. + onHumanInteractionEvent: () => hitlController.refresh(true), enablePlan: chatMode === "planning", enableHitl, ...(activeThreadId diff --git a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx b/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx index 5e4eb93ed1..53d94f68af 100644 --- a/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpCreateKbModal.tsx @@ -1,55 +1,70 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useState, useMemo, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; -import type { TFunction } from "i18next"; import { - Collapse, + Modal, Form, + Input, InputNumber, - Modal, - Select, + Steps, + Upload, + Button, + message, Space, + Divider, + Collapse, Switch, Tooltip, - Upload, - message, + Select, } from "antd"; -import { - InboxOutlined, - QuestionCircleOutlined, - SettingOutlined, -} from "@ant-design/icons"; +import { InboxOutlined, QuestionCircleOutlined } from "@ant-design/icons"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; -import type { - AidpModelItem, - AidpUploadResponse, -} from "@/ext_components/aidp/services/aidpKnowledgeService"; +import type { AidpModelItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; -import { AIDP_ACCEPT_STRING } from "@/const/knowledgeBase"; +import { USER_ROLES } from "@/const/auth"; + +/** + * Antd's Upload component (Dragger) requires ``originFileObj`` to satisfy + * the ``RcFile`` shape (``File`` + ``uid`` + ``lastModifiedDate``). We + * store raw ``File`` objects in component state, so we cast at the + * render boundary. The structural cast is sufficient because antd does + * not read the extra fields — it only requires them to exist for type + * compatibility. + */ +type RcFileLike = File & { uid: string; lastModifiedDate: Date }; +import { + AIDP_ACCEPT_STRING, + AIDP_KNOWLEDGE_BASE_NAME_PATTERN, +} from "@/const/knowledgeBase"; +import { collectUploadedFileIds } from "@/lib/aidpDocumentStatus"; import { partitionAidpFiles, validateAidpFiles, } from "@/services/uploadService"; -import { getAidpUploadFailureDetails } from "@/ext_components/aidp/services/aidpUploadUtils"; -import { collectUploadedFileIds } from "@/lib/aidpDocumentStatus"; -import { useAidpGroupOptions } from "../hooks/useAidpGroupOptions"; -import { - AIDP_MODAL_STYLES, - AidpKnowledgeBaseBasicFields, - AidpKnowledgeBaseModalFooter, - AidpKnowledgeBaseModalHeader, - AidpKnowledgeBasePermissionFields, -} from "./AidpKnowledgeBaseModalParts"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; const { Dragger } = Upload; +// Preferred VLM model name when present in AIDP's available list. +// Falls back to the first model in the list if this specific one is absent. const PREFERRED_VLM_MODEL = "Qwen3-VL-8B-Instruct"; -type AidpPermission = "EDIT" | "READ_ONLY" | "PRIVATE"; +/** + * Default AIDP knowledge base configuration. + * Aligned with sdk/nexent/core/knowledge_base/config.py (build_create_payload defaults). + * + * Required fields per AIDP schema: + * chunk_token_num (> 0), chunk_overlap_num (>= 0) + * Reference fills the rest (is_personal, topk, similarity, smartsplit, caption_enable). + * ``vlm_model`` is no longer a hardcoded constant — it is resolved at runtime + * from the list of models AIDP advertises as applicable to the KnowledgeBase + * application (see ``useQuery(["aidp-models"])``). + */ const AIDP_CREATE_DEFAULTS = { chunk_token_num: 1024, chunk_overlap_num: 128, @@ -58,108 +73,10 @@ const AIDP_CREATE_DEFAULTS = { topk: 10, similarity: 0.0, smartsplit: 1, + // caption_enable: int 0/1. caption_enable: 0, }; -const validateAidpCreateFiles = ( - files: File[], - t: TFunction, - notify: typeof message -) => { - if (files.length === 0) return true; - const validation = validateAidpFiles(files); - if (validation.valid.length === files.length) return true; - partitionAidpFiles(files, t, notify); - return false; -}; - -const getAidpCreatePermissionValues = ( - isUser: boolean, - values: { ingroup_permission?: string; group_ids?: unknown } -) => { - const configuredPermission = values.ingroup_permission; - let permission: AidpPermission = "READ_ONLY"; - if (isUser) { - permission = "PRIVATE"; - } else if ( - configuredPermission === "EDIT" || - configuredPermission === "READ_ONLY" || - configuredPermission === "PRIVATE" - ) { - permission = configuredPermission; - } - const groupIds = - isUser || permission === "PRIVATE" - ? [] - : Array.isArray(values.group_ids) - ? values.group_ids - : []; - return { permission, groupIds }; -}; - -const showAidpCreateUploadResult = ( - result: AidpUploadResponse, - language: string, - t: TFunction -) => { - const failureDetails = getAidpUploadFailureDetails( - result.failed_list, - language, - t("aidpKnowledge.uploadFailed") - ); - const failureLines = failureDetails.map((detail, index) => ( -
{detail}
- )); - const allFailedContent = - failureLines.length > 0 ? ( - failureLines - ) : ( -
{t("aidpKnowledge.uploadFailed")}
- ); - - if (result.summary.failed > 0 && result.summary.success === 0) { - message.warning( -
-
{t("aidpKnowledge.createKbSuccess")}
- {allFailedContent} -
- ); - return; - } - if (result.summary.failed > 0) { - message.info( -
-
{t("aidpKnowledge.createKbSuccess")}
-
- {t("aidpKnowledge.uploadPartial", { - success: result.summary.success, - failed: result.summary.failed, - })} -
- {failureLines} -
- ); - return; - } - message.success( - `${t("aidpKnowledge.createKbSuccess")} | ${t( - "aidpKnowledge.uploadSuccess", - { count: result.summary.success } - )}` - ); -}; - -const getAidpCreateErrorReason = ( - error: unknown, - knowledgeBaseCreated: boolean, - t: TFunction -) => { - if (error instanceof Error && error.message.trim()) return error.message; - return knowledgeBaseCreated - ? t("aidpKnowledge.uploadFailed") - : t("aidpKnowledge.createKbFailed"); -}; - interface AidpCreateKbModalProps { open: boolean; existingKbs: AidpKnowledgeBaseItem[]; @@ -178,79 +95,140 @@ const AidpCreateKbModal: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [form] = Form.useForm(); + const [current, setCurrent] = useState(0); const [loading, setLoading] = useState(false); const [fileList, setFileList] = useState([]); const fileListRef = useRef([]); - const pendingFilesRef = useRef([]); - const rafIdRef = useRef(null); useEffect(() => { fileListRef.current = fileList; }, [fileList]); - const { isUser, canConfigureGroupPermissions, groupOptions } = - useAidpGroupOptions(); + // Antd fires beforeUpload once per file in a multi-select batch. + // The `newFiles` array may-or-may-not be the same reference across the N + // calls (behavior differs between and and antd versions), + // so we cannot rely on reference-equality for a single-call-per-batch guard. + // Instead, we collect each file in beforeUpload and schedule a single + // requestAnimationFrame flush that runs validate/add once per batch. + // This guarantees partitionAidpFiles + toast execute exactly ONCE per + // user selection, regardless of antd's internal dispatch count. + const pendingFilesRef = useRef([]); + const rafIdRef = useRef(null); + // Load the tenant's groups so the user can pick which groups may access + // the new KB. When no tenant context is available we fall back to an + // empty list and disable the group picker. + // NOTE: ``useAuthorizationContext`` is required — it is the only context + // that exposes ``user: User | null`` (with ``tenantId``). The similarly- + // named ``useAuthenticationContext`` only carries ``session`` and the + // plain ``useAuthentication`` hook doesn't carry ``user`` at all. + const { user } = useAuthorizationContext(); + const isUser = user?.role === USER_ROLES.USER; + const canConfigureGroupPermissions = !!user && !isUser; + const tenantId = user?.tenantId ?? null; + const { data: groupListData } = useGroupList( + canConfigureGroupPermissions ? tenantId : null + ); + const groupOptions = useMemo( + () => + (groupListData?.groups ?? []).map((g) => ({ + value: g.group_id, + label: g.group_name, + })), + [groupListData] + ); + const [formValues, setFormValues] = useState<{ + name: string; + description?: string; + vlm_model?: string; + chunk_token_num: number; + chunk_overlap_num: number; + caption_enable: number; + ingroup_permission: "EDIT" | "READ_ONLY" | "PRIVATE"; + group_ids: number[]; + }>({ + name: "", + chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, + chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, + caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, + ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", + group_ids: [], + }); + + // Drive the vlm_model dropdown's visibility off the live Switch value. + // useWatch gives us a re-render whenever caption_enable toggles, without + // forcing the user to manually sync the form value to local state. const captionEnabled = Form.useWatch("caption_enable", form); + // Track in-group permission live so the group_ids picker can be disabled + // at PRIVATE without calling Form.useWatch inside a conditional sub-render + // (which would violate the Rules of Hooks). const ingroupPermission = Form.useWatch("ingroup_permission", form); useEffect(() => { if (!open) return; form.setFieldsValue({ - chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, - chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, - caption_enable: AIDP_CREATE_DEFAULTS.caption_enable === 1, ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", group_ids: [], }); + setFormValues((previous) => ({ + ...previous, + ingroup_permission: isUser ? "PRIVATE" : previous.ingroup_permission, + group_ids: isUser ? [] : previous.group_ids, + })); }, [form, isUser, open]); + // Fetch applicable VLM models from AIDP. Only run when modal is open to + // avoid hitting the (relatively slow) admin endpoint unnecessarily. const { data: vlmModelsData, isLoading: vlmModelsLoading } = useQuery({ queryKey: ["aidp-models", "llm", "KnowledgeBase"], queryFn: () => aidpKnowledgeService.listModels("llm", "KnowledgeBase"), enabled: open, - staleTime: 5 * 60 * 1000, + staleTime: 5 * 60 * 1000, // 5 min }); const vlmModelOptions = useMemo(() => { const models: AidpModelItem[] = vlmModelsData?.models ?? []; return models - .map((model) => model.model_name) - .filter((name): name is string => Boolean(name)); + .map((m) => m.model_name) + .filter( + (name): name is string => typeof name === "string" && name.length > 0 + ); }, [vlmModelsData]); + // Resolve the default VLM model: prefer the hardcoded + // PREFERRED_VLM_MODEL if present, otherwise the first in the list. + // Falls back to PREFERRED_VLM_MODEL (sent to AIDP as-is) when the + // models endpoint returns empty, matching the previous behavior. const defaultVlmModel = useMemo(() => { if (vlmModelOptions.length === 0) return PREFERRED_VLM_MODEL; - return vlmModelOptions.includes(PREFERRED_VLM_MODEL) - ? PREFERRED_VLM_MODEL - : vlmModelOptions[0]; + if (vlmModelOptions.includes(PREFERRED_VLM_MODEL)) + return PREFERRED_VLM_MODEL; + return vlmModelOptions[0]; }, [vlmModelOptions]); + // Pre-populate vlm_model on the form whenever the default is resolved, + // so the user sees a meaningful default on first open. useEffect(() => { - if (!open || !defaultVlmModel) return; - const currentModel = form.getFieldValue("vlm_model"); - if (!currentModel || !vlmModelOptions.includes(currentModel)) { + if (!open) return; + if (!defaultVlmModel) return; + const current = form.getFieldValue("vlm_model"); + if (!current || !vlmModelOptions.includes(current)) { form.setFieldValue("vlm_model", defaultVlmModel); } - }, [defaultVlmModel, form, open, vlmModelOptions]); + }, [open, defaultVlmModel, vlmModelOptions, form]); + // Duplicate name check against existing KBs const existingNames = useMemo( () => new Set( (existingKbs || []) .map((kb) => kb.kds_name?.toLowerCase().trim()) - .filter((name): name is string => Boolean(name)) + .filter((n): n is string => !!n) ), [existingKbs] ); - const handleSubmit = async () => { - let knowledgeBaseCreated = false; - // Files AIDP accepted while creating the KB. Reported to the parent so it - // can keep refreshing the document list until they finish processing. - let uploadedFileIds: string[] = []; - let createdKnowledgeBase: AidpKnowledgeBaseItem | null = null; - + const handleNext = async () => { try { const values = await form.validateFields(); const name = values.name.trim(); @@ -260,63 +238,173 @@ const AidpCreateKbModal: React.FC = ({ return; } - if (!validateAidpCreateFiles(fileList, t, message)) return; - - setLoading(true); - const { permission, groupIds } = getAidpCreatePermissionValues( - isUser, - values - ); - const captionEnable = values.caption_enable ? 1 : 0; - - const created = await aidpKnowledgeService.createKb({ + // Save form values before fields unmount + setFormValues({ name, - description: values.description?.trim() || "", + description: values.description?.trim() || undefined, + vlm_model: values.caption_enable + ? values.vlm_model || defaultVlmModel || undefined + : "", chunk_token_num: values.chunk_token_num ?? AIDP_CREATE_DEFAULTS.chunk_token_num, chunk_overlap_num: values.chunk_overlap_num ?? AIDP_CREATE_DEFAULTS.chunk_overlap_num, + caption_enable: values.caption_enable ? 1 : 0, + // The permission select is disabled at PRIVATE so users cannot pick + // group_ids while PRIVATE; we always coerce to [] for safety. + ingroup_permission: isUser + ? "PRIVATE" + : (values.ingroup_permission ?? "READ_ONLY"), + group_ids: + isUser || (values.ingroup_permission ?? "READ_ONLY") === "PRIVATE" + ? [] + : Array.isArray(values.group_ids) + ? values.group_ids + : [], + }); + setCurrent(1); + } catch { + // form validation error, do nothing + } + }; + + const handleBack = () => { + // Restore formValues into the Form when remounting Step 0, + // since antd Form clears field values when the Form is unmounted. + form.setFieldsValue(formValues); + setCurrent(0); + }; + + const handleSubmit = async (skipUpload: boolean) => { + let knowledgeBaseCreated = false; + // Files accepted by AIDP while creating the KB. Reported to the parent so + // it can keep refreshing the document list until they finish processing. + let uploadedFileIds: string[] = []; + let createdKdsId = ""; + let createdKnowledgeBase: AidpKnowledgeBaseItem | null = null; + try { + if (!formValues.name?.trim()) { + message.error(t("aidpKnowledge.kbNameRequired")); + setCurrent(0); + return; + } + setLoading(true); + + const permission = isUser ? "PRIVATE" : formValues.ingroup_permission; + const groupIds = isUser ? [] : formValues.group_ids; + + // Defense-in-depth: re-validate every file in case beforeUpload was bypassed + if (!skipUpload && fileList.length > 0) { + const validation = validateAidpFiles(fileList); + if (validation.valid.length !== fileList.length) { + setLoading(false); + partitionAidpFiles(fileList, t, message); + return; + } + } + + // Step 1: Create KB + // Aligned with sdk/nexent/core/knowledge_base/mapper.py#build_create_payload + const created = await aidpKnowledgeService.createKb({ + name: formValues.name.trim(), + description: formValues.description || "", + chunk_token_num: formValues.chunk_token_num, + chunk_overlap_num: formValues.chunk_overlap_num, embedding_model: AIDP_CREATE_DEFAULTS.embedding_model, - vlm_model: captionEnable - ? values.vlm_model || defaultVlmModel || "" - : "", + vlm_model: + formValues.caption_enable === 1 + ? formValues.vlm_model || defaultVlmModel || "" + : "", is_personal: AIDP_CREATE_DEFAULTS.is_personal, topk: AIDP_CREATE_DEFAULTS.topk, similarity: AIDP_CREATE_DEFAULTS.similarity, smartsplit: AIDP_CREATE_DEFAULTS.smartsplit, - caption_enable: captionEnable, + caption_enable: formValues.caption_enable, + // v7.1: forward in-group permission + groups to the backend so the + // knowledge-base permission row is created in lockstep with the KB. ingroup_permission: permission, group_ids: groupIds, }); knowledgeBaseCreated = true; + createdKdsId = String(created.kds_id || ""); createdKnowledgeBase = { ...created, - kds_id: String(created.kds_id || ""), - kds_name: created.kds_name || name, - description: created.description ?? values.description?.trim() ?? "", + kds_id: createdKdsId, + kds_name: created.kds_name || formValues.name.trim(), + description: created.description ?? formValues.description ?? "", permission: "EDIT", ingroup_permission: permission, - group_ids: groupIds, + group_ids: permission === "PRIVATE" ? [] : groupIds, resource_status: "ACTIVE", - is_multimodal: captionEnable === 1, + is_multimodal: formValues.caption_enable === 1, }; - if (fileList.length > 0 && created.kds_id) { + // Step 2: Upload files (if any and not skipped) + if (!skipUpload && fileList.length > 0 && created.kds_id) { const result = await aidpKnowledgeService.uploadDocs( created.kds_id, fileList ); - showAidpCreateUploadResult(result, i18n.language, t); uploadedFileIds = collectUploadedFileIds(result.success_list); + + const failureDetails = result.failed_list.map((item) => { + const reason = i18n.language.startsWith("zh") + ? item.reason_zh || item.reason_en + : item.reason_en || item.reason_zh; + return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; + }); + const failureLines = failureDetails.map((detail, index) => ( +
{detail}
+ )); + + if (result.summary.failed > 0 && result.summary.success === 0) { + message.warning( +
+
{t("aidpKnowledge.createKbSuccess")}
+ {failureLines.length > 0 ? ( + failureLines + ) : ( +
{t("aidpKnowledge.uploadFailed")}
+ )} +
+ ); + } else if (result.summary.failed > 0) { + message.info( +
+
{t("aidpKnowledge.createKbSuccess")}
+
+ {t("aidpKnowledge.uploadPartial", { + success: result.summary.success, + failed: result.summary.failed, + })} +
+ {failureLines} +
+ ); + } else { + message.success( + t("aidpKnowledge.createKbSuccess") + + " | " + + t("aidpKnowledge.uploadSuccess", { + count: result.summary.success, + }) + ); + } } else { message.success(t("aidpKnowledge.createKbSuccess")); } handleReset(); - if (createdKnowledgeBase) + if (createdKnowledgeBase) { onSuccess(createdKnowledgeBase, uploadedFileIds); + } } catch (error) { - const reason = getAidpCreateErrorReason(error, knowledgeBaseCreated, t); + const reason = + error instanceof Error && error.message.trim() + ? error.message + : knowledgeBaseCreated + ? t("aidpKnowledge.uploadFailed") + : t("aidpKnowledge.createKbFailed"); message.error( knowledgeBaseCreated ? `${t("aidpKnowledge.createKbSuccess")} | ${reason}` @@ -324,8 +412,9 @@ const AidpCreateKbModal: React.FC = ({ ); if (knowledgeBaseCreated) { handleReset(); - if (createdKnowledgeBase) + if (createdKnowledgeBase) { onSuccess(createdKnowledgeBase, uploadedFileIds); + } } } finally { setLoading(false); @@ -334,12 +423,21 @@ const AidpCreateKbModal: React.FC = ({ const handleReset = () => { form.resetFields(); + setCurrent(0); setFileList([]); pendingFilesRef.current = []; if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } + setFormValues({ + name: "", + chunk_token_num: AIDP_CREATE_DEFAULTS.chunk_token_num, + chunk_overlap_num: AIDP_CREATE_DEFAULTS.chunk_overlap_num, + caption_enable: AIDP_CREATE_DEFAULTS.caption_enable, + ingroup_permission: isUser ? "PRIVATE" : "READ_ONLY", + group_ids: [], + }); }; const handleCancel = () => { @@ -347,253 +445,330 @@ const AidpCreateKbModal: React.FC = ({ onCancel(); }; - const addFiles = (files: File[]) => { - const currentFiles = fileListRef.current; - const existing = new Set(currentFiles.map((file) => file.name)); - const uniqueFiles = files.filter((file) => !existing.has(file.name)); - const { valid } = partitionAidpFiles( - uniqueFiles, - t, - message, - currentFiles.length - ); - if (valid.length > 0) setFileList([...currentFiles, ...valid]); - }; + // ---- Render steps ---- + + const renderStep0 = () => ( + <> +
+ + + + + + + + {canConfigureGroupPermissions && ( + <> + {/* USER creation is always a personal PRIVATE KB. */} + + + + + )} + + + {t("aidpKnowledge.createCaptionEnable")} + + + + + } + > + + + + {/* VLM model picker is only relevant when multimodal captioning is + enabled. Hide the dropdown entirely when the Switch is off so + users aren't shown an irrelevant choice, and so the backend + receives an empty ``vlm_model`` (see handleSubmit). */} + {captionEnabled && ( + + {t("aidpKnowledge.createVlmModel")} + + + + + } + > + ({ - label: name, - value: name, - }))} - filterOption={(input, option) => - (option?.label as string) - ?.toLowerCase() - .includes(input.toLowerCase()) ?? false - } - /> - - )} -
- ), - }, - ]} - /> - -
+ + + {current === 0 && ( + + )} + {current === 1 && fileList.length > 0 && ( + + )} + {current === 1 && ( + + )} + +
+ } + > + + + {current === 0 && renderStep0()} + {current === 1 && renderStep1()} ); }; diff --git a/frontend/ext_components/aidp/components/AidpDocumentList.tsx b/frontend/ext_components/aidp/components/AidpDocumentList.tsx index 8e8e7a0378..b8131da270 100644 --- a/frontend/ext_components/aidp/components/AidpDocumentList.tsx +++ b/frontend/ext_components/aidp/components/AidpDocumentList.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useRef, useState } from "react"; +import React, { useState, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Modal, Pagination, Tag, Upload, message, Tooltip } from "antd"; +import { Button, Pagination, Tag, Upload, message, Tooltip } from "antd"; import { - FileTextOutlined, + UploadOutlined, InboxOutlined, ReloadOutlined, } from "@ant-design/icons"; @@ -12,7 +12,6 @@ import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import type { AidpDocumentItem } from "@/ext_components/aidp/services/aidpKnowledgeService"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; import { AIDP_ACCEPT_STRING } from "@/const/knowledgeBase"; -import log from "@/lib/logger"; import { AIDP_DOC_IN_PROGRESS_STATUSES, AIDP_DOCUMENT_STATUS, @@ -20,7 +19,6 @@ import { normalizeAidpDocStatus, } from "@/lib/aidpDocumentStatus"; import { partitionAidpFiles } from "@/services/uploadService"; -import { getAidpUploadFailureDetails } from "@/ext_components/aidp/services/aidpUploadUtils"; const { Dragger } = Upload; @@ -53,6 +51,28 @@ const isDuplicateUploadReason = ( ); }; +/** Table cell showing a document name above its AIDP file id. */ +const DocumentNameCell: React.FC<{ fileName: string; fileInoNo: string }> = ({ + fileName, + fileInoNo, +}) => ( + + {/* `max-width` on a table cell is ignored by browsers, so the clamp must + live on the inner div; the full value is surfaced on hover through an + antd Tooltip instead of a width measurement. */} + +
+ {fileName} +
+
+ +
+ {fileInoNo} +
+
+ +); + /** * Labels for the in-progress statuses, which all render as a blue tag. * @@ -83,13 +103,13 @@ const DocumentStatusCell: React.FC<{ status?: string }> = ({ status }) => { const normalized = normalizeAidpDocStatus(status); if (!normalized) { - return -; + return -; } if (AIDP_DOC_IN_PROGRESS_STATUSES.includes(normalized)) { const labelKey = IN_PROGRESS_STATUS_LABELS[normalized]; return ( - + {labelKey ? t(labelKey) : status} ); @@ -97,7 +117,7 @@ const DocumentStatusCell: React.FC<{ status?: string }> = ({ status }) => { if (normalized === AIDP_DOCUMENT_STATUS.COMPLETED) { return ( - + {t("aidpKnowledge.docStatusCompleted")} ); @@ -105,50 +125,34 @@ const DocumentStatusCell: React.FC<{ status?: string }> = ({ status }) => { if (normalized === AIDP_DOCUMENT_STATUS.FAILED) { return ( - + {t("aidpKnowledge.docStatusFailed")} ); } return ( - + {status} ); }; -const resolveDownloadFilename = (response: Response, fallback: string) => { - const contentDisposition = response.headers.get("content-disposition") || ""; - const encodedName = /filename\*=UTF-8''([^;]+)/i.exec( - contentDisposition - )?.[1]; - if (encodedName) { - try { - return decodeURIComponent(encodedName); - } catch { - // Use the regular filename or document name when decoding fails. - } - } - const plainName = /filename="?([^";]+)"?/i.exec(contentDisposition)?.[1]; - return plainName || response.headers.get("x-file-name") || fallback; -}; - interface AidpDocumentListProps { activeKb: AidpKnowledgeBaseItem | null; documents: AidpDocumentItem[]; totalDocs: number; - /** True when `totalDocs` came from the AIDP Count API. */ + /** True when `totalDocs` came from the AIDP Count API; when false the + * total is a fallback estimate and "共 N 条" should be suppressed. */ totalReliable: boolean; hasMore: boolean; isLoading: boolean; currentPage: number; pageSize: number; onPageChange: (page: number) => void; - /** Called after documents change, with the ids AIDP returned for any newly - * accepted uploads (an empty list for deletions/refreshes). The parent uses - * them to keep refreshing the list until each uploaded file reports a - * terminal processing status. */ + /** Called after an upload is accepted, with the ids AIDP returned for the + * accepted files. The parent uses them to keep refreshing the list until + * each uploaded file reports a terminal processing status. */ onDocsUploaded: (uploadedFileIds: string[]) => void; onRefresh: () => void; } @@ -168,10 +172,6 @@ const AidpDocumentList: React.FC = ({ }) => { const { t, i18n } = useTranslation(); const [uploading, setUploading] = useState(false); - const [deleting, setDeleting] = useState(false); - const [downloadingFileUuid, setDownloadingFileUuid] = useState( - null - ); // Antd fires beforeUpload once per file in a multi-select batch. // The `fileList` array may-or-may-not be the same reference across the N // calls (behavior differs between and and antd versions), @@ -181,85 +181,10 @@ const AidpDocumentList: React.FC = ({ const pendingFilesRef = useRef([]); const rafIdRef = useRef(null); - const isUnavailable = - activeKb?.resource_status === "UNAVAILABLE" || - activeKb?.resource_status === "ORPHANED"; - const canDeleteDocuments = - !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; - const canDownloadDocuments = - !!activeKb && - !isUnavailable && - (activeKb.permission === "EDIT" || activeKb.permission === "READ_ONLY"); - - const handleDownload = useCallback( - async (document: AidpDocumentItem) => { - if (!activeKb || !document.file_uuid) return; - setDownloadingFileUuid(document.file_uuid); - try { - const response = await aidpKnowledgeService.downloadDoc( - activeKb.kds_id, - document.file_uuid - ); - const blob = await response.blob(); - const downloadUrl = URL.createObjectURL(blob); - const link = window.document.createElement("a"); - link.href = downloadUrl; - link.download = resolveDownloadFilename(response, document.file_name); - link.click(); - URL.revokeObjectURL(downloadUrl); - message.success(t("aidpKnowledge.downloadSuccess")); - } catch (error) { - log.error("Failed to download AIDP document:", error); - message.error(t("aidpKnowledge.downloadFailed")); - } finally { - setDownloadingFileUuid(null); - } - }, - [activeKb, t] - ); - - const handleDelete = useCallback( - (document: AidpDocumentItem) => { - if (!activeKb || !document.file_uuid) return; - Modal.confirm({ - title: t("aidpKnowledge.confirmDeleteDocTitle"), - content: t("aidpKnowledge.confirmDeleteDocContent"), - okText: t("common.confirm"), - cancelText: t("common.cancel"), - okButtonProps: { danger: true }, - centered: true, - onOk: async () => { - setDeleting(true); - try { - const result = await aidpKnowledgeService.removeDoc( - activeKb.kds_id, - document.file_uuid - ); - if (result.summary.success > 0) { - message.success(t("aidpKnowledge.deleteDocSuccess")); - } else { - message.error(t("aidpKnowledge.deleteDocFailed")); - } - if (result.summary.success > 0) { - // Deletions are not uploads: nothing to wait for, so the parent - // simply refreshes the list. - onDocsUploaded([]); - } - } catch (error) { - log.error("Failed to delete AIDP document:", error); - message.error(t("aidpKnowledge.deleteDocFailed")); - } finally { - setDeleting(false); - } - }, - }); - }, - [activeKb, onDocsUploaded, t] - ); - const handleUpload = useCallback( async (fileList: File[]) => { - if (!activeKb || fileList.length === 0) return; + if (!activeKb) return; + if (fileList.length === 0) return; setUploading(true); try { @@ -268,21 +193,20 @@ const AidpDocumentList: React.FC = ({ fileList ); - // A rejected duplicate is not an error the user can debug, so give it a - // dedicated message instead of echoing AIDP's "please rename or delete" - // instruction, which is not actionable in this dialog. Every other - // failure keeps the shared reason formatter. const failureDetails = result.failed_list.map((item) => { + // A duplicate is not an error the user can debug, so give it a + // dedicated message instead of echoing AIDP's "please rename or + // delete" instruction, which is not actionable in this dialog. if (isDuplicateUploadReason(item.reason_zh, item.reason_en)) { return t("aidpKnowledge.uploadDuplicateFile", { fileName: item.file_name, }); } - return getAidpUploadFailureDetails( - [item], - i18n.language, - t("aidpKnowledge.uploadFailed") - )[0]; + + const reason = i18n.language.startsWith("zh") + ? item.reason_zh || item.reason_en + : item.reason_en || item.reason_zh; + return `${item.file_name}: ${reason || t("aidpKnowledge.uploadFailed")}`; }); const failureLines = failureDetails.map((detail, index) => (
{detail}
@@ -328,6 +252,7 @@ const AidpDocumentList: React.FC = ({ [activeKb, i18n.language, onDocsUploaded, t] ); + // Format file size for display const formatSize = (bytes?: number): string => { if (!bytes || bytes === 0) return "-"; if (bytes < 1024) return `${bytes} B`; @@ -337,231 +262,205 @@ const AidpDocumentList: React.FC = ({ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; }; - const effectiveTotal = totalReliable - ? totalDocs - : hasMore - ? currentPage * pageSize + 1 - : currentPage * pageSize; - - const canUpload = - !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; - - const renderUploadArea = () => { - if (!canUpload) { - const reasonKey = !activeKb - ? "aidpKnowledge.uploadNoKb" - : isUnavailable - ? "aidpKnowledge.uploadKbUnavailable" - : "aidpKnowledge.uploadReadOnly"; - return ( -
-

{t(reasonKey)}

-
- ); - } - - return ( - { - pendingFilesRef.current.push(_file); - if (rafIdRef.current === null) { - rafIdRef.current = requestAnimationFrame(() => { - const batch = pendingFilesRef.current; - pendingFilesRef.current = []; - rafIdRef.current = null; - - const { valid } = partitionAidpFiles(batch, t, message); - if (valid.length > 0) void handleUpload(valid); - }); - } - return false; - }} - disabled={uploading} - className="!rounded-xl !border-blue-200 !bg-blue-50/30" - > -

- -

-

- {uploading - ? t("aidpKnowledge.uploading") - : t("aidpKnowledge.uploadHint")} -

-
-
{t("aidpKnowledge.uploadHintCount")}
-
{t("aidpKnowledge.uploadHintSize")}
-
- {t("aidpKnowledge.uploadHintFormats")} -
-
-
- ); - }; - return ( -
-
-
-
- -
-
- {/* An empty `title` leaves the Tooltip inert, so it is safe - while the knowledge base detail is still loading. */} +
+ {/* Header */} +
+
+
-

+

{activeKb?.kds_name || ""} -

+
- + {t("aidpKnowledge.tagDocs", { count: totalDocs })}
+ +
- -
-
+ {/* Document table */} +
{isLoading ? ( -
+
-
-

+

+

{t("aidpKnowledge.loadingDocs")}

) : documents.length > 0 ? ( -
+
- + - - - - - - - + {documents.map((doc) => ( - - - + + - - - ))}
+ {t("aidpKnowledge.docFileName")} + {t("aidpKnowledge.docType")} + {t("aidpKnowledge.docStatus")} + {t("aidpKnowledge.docSize")} + {t("aidpKnowledge.docCreatedAt")} - {t("aidpKnowledge.docActions")} -
- {/* Long file names are clamped by CSS on a - width-bounded inner element (`max-width` on a - table cell is ignored by browsers, so the clamp - must live on the div itself) and the full value - is surfaced through an antd Tooltip on hover, - matching the local knowledge-base list. */} - -
- {doc.file_name} -
-
- -
- {doc.file_ino_no} -
-
-
+
{doc.file_type || "-"} + {formatSize(doc.file_size)} + {doc.created_at ? new Date(doc.created_at).toLocaleString() : "-"} -
- {canDownloadDocuments && ( - - )} - {canDeleteDocuments && ( - - )} -
-
) : ( -
+
{t("aidpKnowledge.noDocuments")}
)}
- {documents.length > 0 && ( -
- t("aidpKnowledge.showTotal", { count }) - : undefined - } - size="small" - /> -
- )} - -
- {renderUploadArea()} + {/* Server-side pagination. + AIDP exposes a dedicated Count API for documents which the backend + now calls alongside the list request. When Count succeeds, + `totalReliable` is true and we display the full pagination (page + numbers + "共 N 条"). When Count fails (e.g. the endpoint is not + available on a particular AIDP instance), `totalReliable` is false + and we fall back to simple prev/next mode without a total, using + `has_more` to decide whether the next-page button should enable. */} + {documents.length > 0 && + (() => { + // When total is unreliable we still need antd to know when to + // enable "next": set total just past the current page if there is + // a next page, otherwise clamp to the current page end. + const effectiveTotal = totalReliable + ? totalDocs + : hasMore + ? currentPage * pageSize + 1 + : currentPage * pageSize; + return ( +
+ t("aidpKnowledge.showTotal", { count: total }) + : undefined + } + size="small" + /> +
+ ); + })()} + + {/* Upload area — gated by ``activeKb.permission`` and ``resource_status``. + + Per v7.1 §7.1, READ_ONLY callers may view existing documents but + must not be able to upload. UNAVAILABLE / ORPHANED KBs are + read-only regardless of permission because the AIDP backend cannot + service the request. The container is replaced with a hint instead + of disabling the Dragger so the visual structure stays consistent + and screen-reader users get an explicit reason. */} +
+ {(() => { + const isUnavailable = + activeKb?.resource_status === "UNAVAILABLE" || + activeKb?.resource_status === "ORPHANED"; + const canUpload = + !!activeKb && !isUnavailable && activeKb.permission === "EDIT"; + if (!canUpload) { + const reasonKey = !activeKb + ? "aidpKnowledge.uploadNoKb" + : isUnavailable + ? "aidpKnowledge.uploadKbUnavailable" + : "aidpKnowledge.uploadReadOnly"; + return ( +
+

{t(reasonKey)}

+
+ ); + } + return ( + { + // Queue the file and defer validation + upload until the + // synchronous batch of beforeUpload calls finishes. Each batch + // flushes in a single frame so toasts and handleUpload run once. + pendingFilesRef.current.push(_file); + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + const batch = pendingFilesRef.current; + pendingFilesRef.current = []; + rafIdRef.current = null; + + const { valid } = partitionAidpFiles(batch, t, message); + if (valid.length > 0) { + handleUpload(valid); + } + }); + } + return false; + }} + disabled={uploading} + > +

+ +

+

+ {uploading + ? t("aidpKnowledge.uploading") + : t("aidpKnowledge.uploadHint")} +

+
+
{t("aidpKnowledge.uploadHintCount")}
+
{t("aidpKnowledge.uploadHintSize")}
+
+ {t("aidpKnowledge.uploadHintFormats")} +
+
+
+ ); + })()}
); diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx deleted file mode 100644 index 85b2d77a87..0000000000 --- a/frontend/ext_components/aidp/components/AidpKnowledgeBaseModalParts.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from "react"; -import { Button, Form, Input, Select } from "antd"; -import type { TFunction } from "i18next"; - -import { AIDP_KNOWLEDGE_BASE_NAME_PATTERN } from "@/const/knowledgeBase"; -import type { AidpGroupOption } from "../hooks/useAidpGroupOptions"; - -export const AIDP_MODAL_STYLES = { - container: { overflow: "hidden", borderRadius: 16, padding: 0 }, - body: { padding: 0 }, - footer: { - margin: 0, - padding: "12px 20px 16px", - borderTop: "1px solid #f0f0f0", - }, -}; - -interface AidpModalHeaderProps { - title: string; - subtitle: string; -} - -export const AidpKnowledgeBaseModalHeader: React.FC = ({ - title, - subtitle, -}) => ( -
-

- {title} -

-

{subtitle}

-
-); - -interface AidpModalFooterProps { - onCancel: () => void; - onSubmit: () => void; - loading: boolean; - cancelText: string; - submitText: string; -} - -export const AidpKnowledgeBaseModalFooter: React.FC = ({ - onCancel, - onSubmit, - loading, - cancelText, - submitText, -}) => ( -
- - -
-); - -interface AidpKnowledgeBaseBasicFieldsProps { - t: TFunction; -} - -export const AidpKnowledgeBaseBasicFields: React.FC< - AidpKnowledgeBaseBasicFieldsProps -> = ({ t }) => ( - <> - - - - - - - -); - -interface AidpPermissionFieldsProps { - t: TFunction; - groupOptions: AidpGroupOption[]; - ingroupPermission?: string; - showSearch?: boolean; -} - -const hasRequiredGroupIds = (permission: string, value: unknown) => - permission === "PRIVATE" || (Array.isArray(value) && value.length > 0); - -export const AidpKnowledgeBasePermissionFields: React.FC< - AidpPermissionFieldsProps -> = ({ t, groupOptions, ingroupPermission, showSearch = false }) => ( - <> - - - - -); diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx index d9463c4988..bef0cf94e7 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeConfiguration.tsx @@ -9,6 +9,7 @@ import { InfoCircleFilled } from "@ant-design/icons"; import { SETUP_PAGE_CONTAINER, TWO_COLUMN_LAYOUT, + STANDARD_CARD, } from "@/const/layoutConstants"; import { KB_SEARCH_DEBOUNCE_MS } from "@/const/knowledgeBase"; import { @@ -45,10 +46,8 @@ const AidpKnowledgeConfiguration: React.FC = () => { // which may not contain the currently active KB. `selectedKb` is the item // itself — set on selection, kept stable across list refetches. const [activeKbId, setActiveKbId] = useState(null); - const [selectedKb, setSelectedKb] = useState( - null - ); - const [, setActiveKbDetail] = useState(null); + const [selectedKb, setSelectedKb] = useState(null); + const [activeKbDetail, setActiveKbDetail] = useState(null); const [documents, setDocuments] = useState([]); const [totalDocs, setTotalDocs] = useState(0); const [docHasMore, setDocHasMore] = useState(false); @@ -67,9 +66,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { // ---- Modal state ---- const [createModalOpen, setCreateModalOpen] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false); - const [editingKb, setEditingKb] = useState( - null - ); + const [editingKb, setEditingKb] = useState(null); // ---- Keyword search state ---- // `kbKeyword` is the raw input value and keeps the text field responsive; @@ -93,7 +90,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { const result = await aidpKnowledgeService.listKbs( page, KB_PAGE_SIZE, - keyword + keyword, ); setKbs(result.value); setKbTotal(result.total_count ?? result.value.length); @@ -151,7 +148,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { const result = await aidpKnowledgeService.listDocs( kbId, page, - DOC_PAGE_SIZE + DOC_PAGE_SIZE, ); const count = result.total_count ?? result.value.length; setDocuments(result.value); @@ -296,7 +293,7 @@ const AidpKnowledgeConfiguration: React.FC = () => { // Refresh list, keeping the active search filter applied fetchKbs(kbPage, debouncedKbKeyword); - } catch { + } catch (error) { appMessage.error(t("aidpKnowledge.deleteKbFailed")); } }, @@ -424,17 +421,18 @@ const AidpKnowledgeConfiguration: React.FC = () => { return (
-
- + {/* Two-column layout — content-sized cards with a single + scroll container; no card stretches to viewport height. */} +
+ {/* Left column: KB list */} { {/* Right column: Document list or empty state */} { onRefresh={handleRefreshDocs} /> ) : ( -
-
-
- +
+
+
+
+ +
+

+ {t("aidpKnowledge.selectKbTitle")} +

+

+ {t("aidpKnowledge.selectKbHint")} +

-

- {t("aidpKnowledge.selectKbTitle")} -

-

- {t("aidpKnowledge.selectKbHint")} -

)} diff --git a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx index b21d0bd9fc..1b54706264 100644 --- a/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx +++ b/frontend/ext_components/aidp/components/AidpKnowledgeList.tsx @@ -1,19 +1,13 @@ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Input, Pagination, Tooltip } from "antd"; +import { Button, Input, Pagination, Tag, Tooltip } from "antd"; import { - BookOpen, - CircleOff, - Eye, - FolderOpen, - Glasses, - PencilRuler, - Search, - SquarePen, - Trash2, -} from "lucide-react"; -import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { SquarePen, Trash2 } from "lucide-react"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import { useGroupList } from "@/hooks/group/useGroupList"; @@ -25,16 +19,14 @@ interface AidpKnowledgeListProps { activeKbId: string | null; isLoading: boolean; total: number; - /** True when `total` came from AIDP Count API. */ + /** True when `total` came from AIDP Count API (reliable). When false we + * show a simple prev/next pagination without "共 N 条". */ totalReliable: boolean; hasMore: boolean; currentPage: number; pageSize: number; /** Raw search box value. The parent debounces it before querying, so this is - * intentionally the un-debounced text the user is currently typing. The - * filtering itself happens server-side: AIDP narrows the catalog by this - * keyword, so the page below renders exactly what the API returned and the - * reported total always matches the rendered cards. */ + * intentionally the un-debounced text the user is currently typing. */ keyword: string; onKeywordChange: (value: string) => void; onPageChange: (page: number) => void; @@ -45,20 +37,6 @@ interface AidpKnowledgeListProps { onDelete: (kb: AidpKnowledgeBaseItem) => void; } -const permissionIcon = (permission?: string) => { - const props = { size: 13, className: "text-gray-500" }; - switch (permission) { - case "EDIT": - return ; - case "READ_ONLY": - return ; - case "PRIVATE": - return ; - default: - return ; - } -}; - const AidpKnowledgeList: React.FC = ({ kbs, activeKbId, @@ -79,322 +57,235 @@ const AidpKnowledgeList: React.FC = ({ }) => { const { t } = useTranslation(); + // Load groups for the current tenant so we can render group_ids as names. + // ``useAuthorizationContext`` is the right hook here (the similarly-named + // ``useAuthenticationContext`` carries only ``session`` — no ``user`` object). const { user } = useAuthorizationContext(); const tenantId = user?.tenantId ?? null; const { data: groupListData } = useGroupList(tenantId); const groupById = useMemo(() => { const map = new Map(); - (groupListData?.groups ?? []).forEach((group) => { - map.set(group.group_id, group.group_name); + (groupListData?.groups ?? []).forEach((g) => { + map.set(g.group_id, g.group_name); }); return map; }, [groupListData]); - // The keyword filter lives on the server: the parent forwards it to AIDP, and - // filtering the returned page again here would hide results whenever AIDP's - // matching is not a plain substring of the name/description. Only the order is - // local, so the most recently updated KB stays on top of the page. - const displayedKbs = useMemo( - () => - [...kbs].sort((a, b) => { - const aTime = Date.parse(a.updated_at || a.created_at || "") || 0; - const bTime = Date.parse(b.updated_at || b.created_at || "") || 0; - return bTime - aTime; - }), - [kbs] - ); - - const getGroupNames = (groupIds?: number[]) => - (groupIds ?? []) + // Convert group ids to names, skipping any ids that don't resolve + // (e.g. the group was deleted or the list is not yet loaded). Aligned + // with the local-knowledge-base list renderer. + const getGroupNames = (groupIds: number[] | undefined): string[] => { + if (!Array.isArray(groupIds) || groupIds.length === 0) return []; + return groupIds .map((id) => groupById.get(id)) - .filter((name): name is string => Boolean(name)); - - const permissionLabel = (permission?: string) => - t(`knowledgeBase.ingroup.permission.${permission || "DEFAULT"}`); - - const renderStatusTag = (kb: AidpKnowledgeBaseItem) => { - const isUnavailable = - kb.resource_status === "UNAVAILABLE" || kb.resource_status === "ORPHANED"; - if (isUnavailable) { - return ( - - {t("aidpKnowledge.kbUnavailable")} - - ); - } - if (kb.permission === "READ_ONLY") { - return ( - - {t("aidpKnowledge.kbReadOnly")} - - ); - } - return null; + .filter((name): name is string => typeof name === "string" && name.length > 0); }; - const renderKnowledgeCard = (kb: AidpKnowledgeBaseItem) => { - const isActive = activeKbId === kb.kds_id; - const isUnavailable = - kb.resource_status === "UNAVAILABLE" || kb.resource_status === "ORPHANED"; - const canModify = kb.permission === "EDIT" && !isUnavailable; - const groupNames = getGroupNames(kb.group_ids); - const documentCount = kb.document_count ?? 0; - const chunkCount = kb.chunk_count ?? 0; - const permission = kb.ingroup_permission || "PRIVATE"; - - return ( -
onSelect(kb)} - onKeyDown={(event) => { - if (event.target !== event.currentTarget) return; - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelect(kb); - } - }} - > -
-
-
- -
-
-

- {kb.kds_name} -

- - AIDP - -
-
- -
- {canModify && ( - -
-
- -

- {kb.description?.trim() || t("knowledgeBase.description.default")} -

- -
- - {t("knowledgeBase.tag.documents", { count: documentCount })} - - - {t("knowledgeBase.tag.chunks", { count: chunkCount })} - - {kb.embedding_model && kb.embedding_model !== "default" && ( - - {kb.embedding_model} - - )} - {kb.is_multimodal && ( - - multimodal - - )} - {renderStatusTag(kb)} - - {permission === "PRIVATE" ? ( - - {permissionIcon(permission)} - {permissionLabel(permission)} - - ) : ( - groupNames.slice(0, 2).map((groupName) => ( - - {groupName} - - )) - )} - -
- -
- - {t("knowledgeBase.tag.updatedAt", { - date: - kb.updated_at || kb.created_at - ? new Date( - kb.updated_at || kb.created_at || "" - ).toLocaleDateString() - : t("aidpKnowledge.createdAtUnknown"), - })} - - - {permissionLabel(permission)} - -
-
+ // Sort alphabetically by name + const displayedKbs = useMemo(() => { + return [...kbs].sort((a, b) => + (a.kds_name || "").localeCompare(b.kds_name || "") ); - }; - - let effectiveTotal = currentPage * pageSize; - if (totalReliable) { - effectiveTotal = total; - } else if (hasMore) { - effectiveTotal += 1; - } + }, [kbs]); return ( -
-
-
-
-
- -
-
-

- {t("knowledgeBase.page.title")} -

-

- {t("knowledgeBase.page.description")} -

-
-
- -
+
+ {/* Header */} +
+
+

+ {t("aidpKnowledge.kbListTitle")} +

+
- -
-

- {t("knowledgeBase.page.all")} - - {t("knowledgeBase.page.count", { count: total })} - -

- } - value={keyword} - onChange={(event) => onKeywordChange(event.target.value)} - className="h-10 min-w-[240px] max-w-[420px] flex-1 !rounded-lg" - allowClear - /> -
+ {/* Search box. `keyword` is the raw input value and the parent debounces + it, so typing stays responsive and only the settled text triggers a + request. Clearing the field restores the unfiltered list. */} + } + value={keyword} + onChange={(e) => onKeywordChange(e.target.value)} + size="small" + />
-
- {isLoading && kbs.length === 0 ? ( -
- Loading... + {/* List */} +
+ {displayedKbs.length > 0 ? ( +
+ {displayedKbs.map((kb) => { + const isActive = activeKbId === kb.kds_id; + const isUnavailable = + kb.resource_status === "UNAVAILABLE" || + kb.resource_status === "ORPHANED"; + // Only EDIT-level callers may modify the KB or its files. + const canModify = kb.permission === "EDIT" && !isUnavailable; + + return ( +
onSelect(kb)} + > +
+
+
+

+ {kb.kds_name} +

+ {isUnavailable && ( + + {t("aidpKnowledge.kbUnavailable")} + + )} + {kb.permission === "READ_ONLY" && !isUnavailable && ( + + {t("aidpKnowledge.kbReadOnly")} + + )} +
+ {kb.description && ( +

+ {kb.description} +

+ )} +
+ {kb.ingroup_permission === "PRIVATE" && ( + + {t("knowledgeBase.ingroup.permission.PRIVATE")} + + )} + {/* Authorized user-group tags. Aligned with the local + knowledge base list: only render group names when + ``ingroup_permission !== "PRIVATE"``, each group + gets its own blue tag, and when there are no + groups to show we render nothing (no "not + authorized" fallback). Gated by the ``group:read`` + permission so users without group visibility see + the KB card cleanly without the tag area. */} + + {kb.ingroup_permission !== "PRIVATE" && + getGroupNames(kb.group_ids).map((groupName, idx) => ( + + {groupName} + + ))} + + {kb.created_at ? ( + + {t("aidpKnowledge.createdAt", { + date: new Date(kb.created_at).toLocaleDateString(), + })} + + ) : ( + {t("aidpKnowledge.createdAtUnknown")} + )} +
+
+
+ {canModify && ( + +
+
+
+ ); + })}
) : ( -
- - {displayedKbs.map(renderKnowledgeCard)} -
- )} - - {!isLoading && displayedKbs.length === 0 && ( -
+
{keyword.trim() - ? t("knowledgeBase.list.noResults") + ? t("aidpKnowledge.searchEmpty") : t("aidpKnowledge.listEmpty")}
)}
- {kbs.length > 0 && ( -
- t("aidpKnowledge.showTotal", { count }) - : undefined - } - size="small" - /> -
- )} + {/* Server-side pagination. + AIDP exposes a dedicated Count API for KBs which the backend calls + alongside the list request. When Count succeeds, `totalReliable` + is true and we display the full pagination (page numbers + + "共 N 条"). When Count fails (e.g. endpoint unavailable), we fall + back to simple prev/next mode using `has_more`. */} + {kbs.length > 0 && (() => { + const effectiveTotal = totalReliable + ? total + : (hasMore + ? currentPage * pageSize + 1 + : currentPage * pageSize); + return ( +
+ t("aidpKnowledge.showTotal", { count: total }) + : undefined + } + size="small" + /> +
+ ); + })()}
); }; diff --git a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx index 415290f067..cb9a0317e5 100644 --- a/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx +++ b/frontend/ext_components/aidp/components/AidpUpdateKbModal.tsx @@ -1,21 +1,16 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Collapse, Modal, Form, message } from "antd"; -import { SettingOutlined } from "@ant-design/icons"; +import { Modal, Form, Input, Select, message } from "antd"; import type { AidpKnowledgeBaseItem } from "@/types/agentConfig"; import aidpKnowledgeService from "@/ext_components/aidp/services/aidpKnowledgeService"; -import { useAidpGroupOptions } from "../hooks/useAidpGroupOptions"; -import { - AIDP_MODAL_STYLES, - AidpKnowledgeBaseBasicFields, - AidpKnowledgeBaseModalFooter, - AidpKnowledgeBaseModalHeader, - AidpKnowledgeBasePermissionFields, -} from "./AidpKnowledgeBaseModalParts"; +import { AIDP_KNOWLEDGE_BASE_NAME_PATTERN } from "@/const/knowledgeBase"; +import { useGroupList } from "@/hooks/group/useGroupList"; +import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; +import { USER_ROLES } from "@/const/auth"; interface AidpUpdateKbModalProps { open: boolean; @@ -34,9 +29,24 @@ const AidpUpdateKbModal: React.FC = ({ const [form] = Form.useForm(); const [loading, setLoading] = React.useState(false); - const { isUser, canConfigureGroupPermissions, groupOptions } = - useAidpGroupOptions(); - const [advancedOpen, setAdvancedOpen] = React.useState(false); + // Mirror the create-modal wiring: the authorization context exposes + // ``user.tenantId``, which we feed into ``useGroupList`` to enumerate + // the tenant's groups for the access-group picker below. + const { user } = useAuthorizationContext(); + const isUser = user?.role === USER_ROLES.USER; + const canConfigureGroupPermissions = !!user && !isUser; + const tenantId = user?.tenantId ?? null; + const { data: groupListData } = useGroupList( + canConfigureGroupPermissions ? tenantId : null + ); + const groupOptions = useMemo( + () => + (groupListData?.groups ?? []).map((g) => ({ + value: g.group_id, + label: g.group_name, + })), + [groupListData] + ); const ingroupPermission = Form.useWatch("ingroup_permission", form); @@ -44,21 +54,20 @@ const AidpUpdateKbModal: React.FC = ({ // that predate the column — normalize to an empty array so the Select // (mode="multiple") receives a value shape it accepts. useEffect(() => { - if (!open) return; - setAdvancedOpen(false); - if (!knowledgeBase) return; - form.setFieldsValue({ - name: knowledgeBase.kds_name, - description: knowledgeBase.description || "", - ingroup_permission: isUser - ? "PRIVATE" - : knowledgeBase.ingroup_permission || "READ_ONLY", - group_ids: isUser - ? [] - : Array.isArray(knowledgeBase.group_ids) - ? knowledgeBase.group_ids - : [], - }); + if (open && knowledgeBase) { + form.setFieldsValue({ + name: knowledgeBase.kds_name, + description: knowledgeBase.description || "", + ingroup_permission: isUser + ? "PRIVATE" + : knowledgeBase.ingroup_permission || "READ_ONLY", + group_ids: isUser + ? [] + : Array.isArray(knowledgeBase.group_ids) + ? knowledgeBase.group_ids + : [], + }); + } }, [open, knowledgeBase, form, isUser]); const handleOk = async () => { @@ -155,74 +164,96 @@ const AidpUpdateKbModal: React.FC = ({ return ( - } > -
- -
+ - - {canConfigureGroupPermissions && ( - - setAdvancedOpen( - Array.isArray(keys) - ? keys.includes("advanced") - : keys === "advanced" - ) - } - items={[ + + + + + + {canConfigureGroupPermissions && ( + <> + - - {t("aidpKnowledge.createAdvancedOptions")} - - ), - children: ( -
- -
- ), + required: true, + message: t("aidpKnowledge.createIngroupPermissionRequired"), }, ]} - /> - )} - -
+ > + + + + )} +
); }; diff --git a/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts b/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts deleted file mode 100644 index ff0a42e36b..0000000000 --- a/frontend/ext_components/aidp/hooks/useAidpGroupOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo } from "react"; - -import { USER_ROLES } from "@/const/auth"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useGroupList } from "@/hooks/group/useGroupList"; - -export interface AidpGroupOption { - value: number; - label: string; -} - -export const useAidpGroupOptions = () => { - const { user } = useAuthorizationContext(); - const isUser = user?.role === USER_ROLES.USER; - const canConfigureGroupPermissions = Boolean(user) && !isUser; - const tenantId = user?.tenantId ?? null; - const { data: groupListData } = useGroupList( - canConfigureGroupPermissions ? tenantId : null - ); - const groupOptions = useMemo( - () => - (groupListData?.groups ?? []).map((group) => ({ - value: group.group_id, - label: group.group_name, - })), - [groupListData] - ); - - return { isUser, canConfigureGroupPermissions, groupOptions }; -}; diff --git a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts index 80fb4830e8..21b40240df 100644 --- a/frontend/ext_components/aidp/services/aidpKnowledgeService.ts +++ b/frontend/ext_components/aidp/services/aidpKnowledgeService.ts @@ -1,7 +1,7 @@ /** * AIDP Knowledge Base Management Service * - * Wraps the AIDP management backend endpoints. + * Wraps the 8 AIDP management backend endpoints. * Credentials (server_url, api_key) are read by the backend from environment variables. */ @@ -29,12 +29,15 @@ export interface AidpKbDetail { ingroup_permission?: "EDIT" | "READ_ONLY" | "PRIVATE"; group_ids?: number[]; resource_status?: - "ACTIVE" | "CREATING" | "DELETE_PENDING" | "ORPHANED" | "UNAVAILABLE"; + | "ACTIVE" + | "CREATING" + | "DELETE_PENDING" + | "ORPHANED" + | "UNAVAILABLE"; } export interface AidpDocumentItem { - file_uuid: string; - file_ino_no: number; + file_ino_no: string; file_name: string; file_size?: number; file_type?: string; @@ -67,7 +70,6 @@ export interface AidpDocumentListResponse { } export interface AidpUploadSuccessItem { - file_uuid: string; file_name: string; file_type: string; file_size: number; @@ -91,62 +93,6 @@ export interface AidpUploadResponse { failed_list: AidpUploadFailedItem[]; } -export interface AidpDocumentOperationItem { - file_uuid: string; -} - -export interface AidpDocumentRemoveResponse { - summary: { - total: number; - success: number; - failed: number; - }; - success_list: AidpDocumentOperationItem[]; - failed_list: AidpDocumentOperationItem[]; -} - -type AidpOperationSummary = { - total: number; - success: number; - failed: number; -}; - -type AidpOperationResponse = { - summary: AidpOperationSummary; - success_list: TSuccess[]; - failed_list: TFailure[]; -}; - -const normalizeAidpOperationResponse = ( - result: Partial> -): AidpOperationResponse => { - const successList: TSuccess[] = Array.isArray(result.success_list) - ? result.success_list - : []; - const failedList: TFailure[] = Array.isArray(result.failed_list) - ? result.failed_list - : []; - - return { - summary: { - total: - typeof result.summary?.total === "number" - ? result.summary.total - : successList.length + failedList.length, - success: - typeof result.summary?.success === "number" - ? result.summary.success - : successList.length, - failed: - typeof result.summary?.failed === "number" - ? result.summary.failed - : failedList.length, - }, - success_list: successList, - failed_list: failedList, - }; -}; - export interface AidpModelItem { /** Display / identifier used for the model (sent to AIDP as ``vlm_model``). */ model_name: string; @@ -421,10 +367,31 @@ class AidpKnowledgeService { } const result = (await response.json()) as Partial; - return normalizeAidpOperationResponse< - AidpUploadSuccessItem, - AidpUploadFailedItem - >(result); + const successList = Array.isArray(result.success_list) + ? result.success_list + : []; + const failedList = Array.isArray(result.failed_list) + ? result.failed_list + : []; + + return { + summary: { + total: + typeof result.summary?.total === "number" + ? result.summary.total + : successList.length + failedList.length, + success: + typeof result.summary?.success === "number" + ? result.summary.success + : successList.length, + failed: + typeof result.summary?.failed === "number" + ? result.summary.failed + : failedList.length, + }, + success_list: successList, + failed_list: failedList, + }; } /** @@ -511,46 +478,6 @@ class AidpKnowledgeService { : undefined, }; } - - /** - * Remove one document from an AIDP knowledge base. - * The AIDP API accepts an array, so the single-document UI sends one item. - */ - async removeDoc( - id: string, - fileUuid: string - ): Promise { - const url = buildUrl(API_ENDPOINTS.aidpMgmt.removeKbDocuments(id), {}); - const response = await fetchWithErrorHandling(url, { - method: "POST", - headers: { - ...getAuthHeaders(), - "Content-Type": "application/json", - }, - body: JSON.stringify({ file_uuids: [fileUuid] }), - }); - const result = - (await response.json()) as Partial; - return normalizeAidpOperationResponse< - AidpDocumentOperationItem, - AidpDocumentOperationItem - >(result); - } - - /** - * Download one document through the AIDP management backend. - */ - async downloadDoc(id: string, fileUuid: string): Promise { - const url = buildUrl(API_ENDPOINTS.aidpMgmt.downloadKbDocument(id), {}); - return fetchWithErrorHandling(url, { - method: "POST", - headers: { - ...getAuthHeaders(), - "Content-Type": "application/json", - }, - body: JSON.stringify({ file_uuid: fileUuid }), - }); - } } const aidpKnowledgeService = new AidpKnowledgeService(); diff --git a/frontend/ext_components/aidp/services/aidpUploadUtils.ts b/frontend/ext_components/aidp/services/aidpUploadUtils.ts deleted file mode 100644 index 98de81dfb3..0000000000 --- a/frontend/ext_components/aidp/services/aidpUploadUtils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AidpUploadFailedItem } from "./aidpKnowledgeService"; - -export const getAidpUploadFailureDetails = ( - failedList: AidpUploadFailedItem[], - language: string, - fallbackMessage: string -) => { - const isChinese = language.startsWith("zh"); - return failedList.map((item) => { - const reason = isChinese - ? item.reason_zh || item.reason_en - : item.reason_en || item.reason_zh; - return `${item.file_name}: ${reason || fallbackMessage}`; - }); -}; diff --git a/frontend/features/humanInteraction/client.ts b/frontend/features/humanInteraction/client.ts index 261c08f922..29547749e5 100644 --- a/frontend/features/humanInteraction/client.ts +++ b/frontend/features/humanInteraction/client.ts @@ -37,6 +37,11 @@ export type HumanDecision = "answer" | "approve" | "reject" | "steer"; const base = `${API_BASE_URL}/agent/human-interactions`; +// A hung request (half-open proxy/socket) would otherwise keep the +// controller's in-flight guard stuck forever and silently drop every +// subsequent snapshot, including forced ones. Abort and surface an error. +const REQUEST_TIMEOUT_MS = 15000; + export class HumanInteractionHttpError extends Error { constructor( message: string, @@ -50,6 +55,7 @@ async function request(path: string, body?: unknown): Promise { const response = await fetchWithAuth(`${base}${path}`, { method: body === undefined ? "GET" : "POST", headers: { ...getAuthHeaders(), "Content-Type": "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (!response.ok) { diff --git a/frontend/features/humanInteraction/useHumanInteractionController.ts b/frontend/features/humanInteraction/useHumanInteractionController.ts index 9c45849c4c..bd27751a65 100644 --- a/frontend/features/humanInteraction/useHumanInteractionController.ts +++ b/frontend/features/humanInteraction/useHumanInteractionController.ts @@ -19,6 +19,8 @@ const ACTIVE_STATUSES = new Set([ "RECOVERY_REQUIRED", ]); const STREAM_RECONNECT_STATUSES = new Set(["READY", "RUNNING"]); +// Safety-net snapshot poll while a run is active (see the polling effect below). +const ACTIVE_SNAPSHOT_POLL_INTERVAL_MS = 5000; const TERMINAL_STATUSES = new Set([ "COMPLETED", "FAILED", @@ -42,7 +44,7 @@ export interface HumanInteractionController { decision: HumanDecision, response: string | HumanClarificationAnswer[] ) => Promise; - refresh: () => Promise; + refresh: (force?: boolean) => Promise; } export function useHumanInteractionController({ @@ -71,6 +73,12 @@ export function useHumanInteractionController({ const refreshInFlight = useRef(false); const lastSnapshotAt = useRef(0); const MIN_SNAPSHOT_INTERVAL_MS = 3000; + // A forced refresh that arrived while another snapshot was in flight is + // re-run afterwards, so critical HITL transitions are never dropped. + const pendingForceRefresh = useRef(false); + const refreshRef = useRef< + (force?: boolean) => Promise | undefined + >(undefined); // Mirror of the latest `run` state; keeps `refresh` dependencies stable. const runRef = useRef(null); activeConversation.current = conversationId; @@ -98,7 +106,7 @@ export function useHumanInteractionController({ }; }, []); - const refresh = useCallback(async () => { + const refresh = useCallback(async (force = false) => { const requestedConversation = conversationId; if (!conversationId) { runRef.current = null; @@ -106,13 +114,16 @@ export function useHumanInteractionController({ setActiveRunId(null); return null; } - // Guard 1: coalesce while a snapshot is in flight. + // Guard 1: coalesce while a snapshot is in flight. Forced callers are + // re-run once the in-flight snapshot completes instead of being dropped. if (refreshInFlight.current) { + if (force) pendingForceRefresh.current = true; return runRef.current; } - // Guard 2: respect the minimum snapshot interval. + // Guard 2: respect the minimum snapshot interval; forced calls bypass it + // because they carry run-state transitions (e.g. ask_user suspends). const now = Date.now(); - if (now - lastSnapshotAt.current < MIN_SNAPSHOT_INTERVAL_MS) { + if (!force && now - lastSnapshotAt.current < MIN_SNAPSHOT_INTERVAL_MS) { return runRef.current; } refreshInFlight.current = true; @@ -146,9 +157,19 @@ export function useHumanInteractionController({ return null; } finally { refreshInFlight.current = false; + if (pendingForceRefresh.current) { + pendingForceRefresh.current = false; + // Routed through the latest refresh closure, which re-validates the + // active conversation and sequence before applying a snapshot. + void refreshRef.current?.(true); + } } }, [conversationId]); + useEffect(() => { + refreshRef.current = refresh; + }, [refresh]); + /** * Discovery: one-shot snapshots at conversation change and agent pause. * After a snapshot reveals a run, the SSE subscription below keeps it live. @@ -197,15 +218,29 @@ export function useHumanInteractionController({ // auto-reconnecting against a run that already finished. try { const parsed = JSON.parse(ev.data); + if (parsed.type === "human_run") { + if ( + parsed.content && + typeof parsed.content === "object" && + TERMINAL_STATUSES.has(parsed.content.status) + ) { + stoppedByUs = true; + es.close(); + void refresh(true); + return; + } + // Status transitions (e.g. ask_user → WAITING_HUMAN) carry the + // request form; they must not be dropped by the snapshot throttle — + // the event stream goes quiet afterwards, so nothing re-triggers. + void refresh(true); + return; + } if ( - parsed.type === "human_run" && - parsed.content && - typeof parsed.content === "object" && - TERMINAL_STATUSES.has(parsed.content.status) + ["human_interaction", "human_decision", "human_execution"].includes( + parsed.type + ) ) { - stoppedByUs = true; - es.close(); - void refresh(); + void refresh(true); return; } } catch { @@ -267,6 +302,20 @@ export function useHumanInteractionController({ const scopedRun = run?.conversation_id === conversationId ? run : null; const active = Boolean(scopedRun && ACTIVE_STATUSES.has(scopedRun.status)); + // Safety-net polling: the SSE stream is the primary delivery channel, but a + // silently stalled connection (hung dev proxy, half-open socket that never + // raises an error event) must not hide a pending form until a manual page + // refresh. The read-only snapshot is cheap; poll it at a low frequency while + // the scoped run is still active. Calls share the refresh() throttle and + // in-flight guard, so live SSE delivery simply coalesces with these ticks. + useEffect(() => { + if (!available || !conversationId || !active) return; + const timer = setInterval(() => { + void refresh(); + }, ACTIVE_SNAPSHOT_POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [available, conversationId, active, refresh]); + const control = useCallback( async (action: "pause" | "terminate") => { if (!run) return; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 81681aaa6e..3b0cbd0bb8 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -844,37 +844,7 @@ "knowledgeBase.error.syncFailed": "Failed to sync DataMate knowledge bases", "knowledgeBase.message.testingConnection": "Testing connection...", "knowledgeBase.message.testingSync": "Syncing knowledge bases...", - "knowledgeBase.page.title": "Knowledge Base", - "knowledgeBase.page.description": "Create, manage, and maintain your team's knowledge assets", - "knowledgeBase.page.all": "All Knowledge Bases", - "knowledgeBase.page.count": "{{count}} total", - "knowledgeBase.page.back": "Back to knowledge bases", - "knowledgeBase.create.subtitle": "Configure basic information and adjust it later at any time", - "knowledgeBase.create.field.name": "Knowledge base name", - "knowledgeBase.create.field.description": "Description", - "knowledgeBase.create.optional": "(Optional)", - "knowledgeBase.create.namePlaceholder": "e.g. Product Knowledge Hub", - "knowledgeBase.create.descriptionPlaceholder": "Briefly describe what this knowledge base contains", - "knowledgeBase.create.advancedSettings": "Advanced settings", - "knowledgeBase.create.submit": "Create and enter", - "knowledgeBase.create.field.embeddingModel": "Embedding model", - "knowledgeBase.create.field.groups": "User groups", - "knowledgeBase.create.field.permission": "Group permission", - "knowledgeBase.create.field.preserve": "Document copy", - "knowledgeBase.create.field.quota": "Storage quota", - "knowledgeBase.create.uploadTitle": "Upload documents to build your knowledge base", "knowledgeBase.list.title": "Knowledge Base List", - "knowledgeBase.personalCapacity.title": "Personal knowledge base capacity", - "knowledgeBase.personalCapacity.withQuota": "Used {{used}} / Total {{quota}}", - "knowledgeBase.personalCapacity.unlimited": "Used {{used}} / Unlimited", - "knowledgeBase.personalCapacity.available": "Available", - "knowledgeBase.personalCapacity.total": "Total capacity", - "knowledgeBase.personalCapacity.unlimitedValue": "Unlimited", - "knowledgeBase.personalCapacity.loadFailed": "Personal knowledge base capacity is temporarily unavailable", - "knowledgeBase.capacity.title": "Storage capacity", - "knowledgeBase.capacity.available": "Available", - "knowledgeBase.capacity.total": "Total capacity", - "knowledgeBase.capacity.unlimited": "Unlimited", "knowledgeBase.button.create": "Create", "knowledgeBase.button.sync": "Sync", "knowledgeBase.button.syncDataMate": "Sync DataMate Knowledge Bases", @@ -897,11 +867,6 @@ "knowledgeBase.search.placeholder": "Search knowledge base name", "knowledgeBase.filter.source.placeholder": "Filter by source", "knowledgeBase.filter.model.placeholder": "Filter by model", - "knowledgeBase.filter.title": "Filter knowledge bases", - "knowledgeBase.filter.button": "Filter", - "knowledgeBase.card.create": "Create knowledge base", - "knowledgeBase.card.createDescription": "Upload documents to build a knowledge asset", - "knowledgeBase.tag.updatedAt": "Updated {{date}}", "knowledgeBase.filter.clear": "Clear filters", "knowledgeBase.source.nexent": "{productName}", "knowledgeBase.source.datamate": "DataMate", @@ -998,7 +963,6 @@ "document.hint.uploadToCreate": "Please select files to upload to complete knowledge base creation", "document.hint.noDocuments": "No documents in this knowledge base, please upload documents", "document.table.header.name": "Document Name", - "document.table.header.tags": "Tags", "document.table.header.status": "Status", "document.table.header.size": "Size", "document.table.header.date": "Upload Date", @@ -4005,6 +3969,8 @@ "aidpKnowledge.kbUnavailable": "Unavailable", "aidpKnowledge.createKb": "Create Knowledge Base", "aidpKnowledge.refresh": "Refresh", + "aidpKnowledge.searchPlaceholder": "Search knowledge bases", + "aidpKnowledge.searchEmpty": "No matching knowledge bases", "aidpKnowledge.listEmpty": "No knowledge bases found", "aidpKnowledge.tagDocs": "docs: {{count}}", "aidpKnowledge.tagChunks": "chunks: {{count}}", @@ -4045,15 +4011,6 @@ "aidpKnowledge.docStatusFailed": "Failed", "aidpKnowledge.docSize": "Size", "aidpKnowledge.docCreatedAt": "Created At", - "aidpKnowledge.docActions": "Actions", - "aidpKnowledge.download": "Download", - "aidpKnowledge.delete": "Delete", - "aidpKnowledge.downloadSuccess": "File download started", - "aidpKnowledge.downloadFailed": "Failed to download file", - "aidpKnowledge.confirmDeleteDocTitle": "Delete document", - "aidpKnowledge.confirmDeleteDocContent": "Are you sure you want to delete this document? This cannot be undone.", - "aidpKnowledge.deleteDocSuccess": "Document deleted successfully", - "aidpKnowledge.deleteDocFailed": "Failed to delete document", "aidpKnowledge.noDocuments": "No documents yet", "aidpKnowledge.loadingDocs": "Loading documents...", "aidpKnowledge.uploadSuccess": "{{count}} document(s) uploaded successfully", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index fe9c0613fd..3f7d9f9326 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -814,37 +814,7 @@ "knowledgeBase.error.syncFailed": "同步 DataMate 知识库失败", "knowledgeBase.message.testingConnection": "正在测试连接...", "knowledgeBase.message.testingSync": "正在同步知识库...", - "knowledgeBase.page.title": "知识库", - "knowledgeBase.page.description": "创建、管理并维护团队的知识资产", - "knowledgeBase.page.all": "全部知识库", - "knowledgeBase.page.count": "共 {{count}} 个", - "knowledgeBase.page.back": "返回知识库", - "knowledgeBase.create.subtitle": "配置基本信息,后续可随时调整", - "knowledgeBase.create.field.name": "知识库名称", - "knowledgeBase.create.field.description": "描述", - "knowledgeBase.create.optional": "(选填)", - "knowledgeBase.create.namePlaceholder": "例如:产品知识中心", - "knowledgeBase.create.descriptionPlaceholder": "简要说明这个知识库包含什么内容", - "knowledgeBase.create.advancedSettings": "高级设置", - "knowledgeBase.create.submit": "创建并进入", - "knowledgeBase.create.field.embeddingModel": "向量模型", - "knowledgeBase.create.field.groups": "所属用户组", - "knowledgeBase.create.field.permission": "组内权限", - "knowledgeBase.create.field.preserve": "文档副本", - "knowledgeBase.create.field.quota": "存储配额", - "knowledgeBase.create.uploadTitle": "上传文档,构建知识库", "knowledgeBase.list.title": "知识库列表", - "knowledgeBase.personalCapacity.title": "个人知识库容量", - "knowledgeBase.personalCapacity.withQuota": "已用 {{used}} / 总容量 {{quota}}", - "knowledgeBase.personalCapacity.unlimited": "已用 {{used}} / 无限制", - "knowledgeBase.personalCapacity.available": "可用容量", - "knowledgeBase.personalCapacity.total": "总容量", - "knowledgeBase.personalCapacity.unlimitedValue": "无限制", - "knowledgeBase.personalCapacity.loadFailed": "个人知识库容量暂时无法加载", - "knowledgeBase.capacity.title": "存储容量", - "knowledgeBase.capacity.available": "可用容量", - "knowledgeBase.capacity.total": "总容量", - "knowledgeBase.capacity.unlimited": "无限制", "knowledgeBase.button.create": "创建", "knowledgeBase.button.sync": "同步", "knowledgeBase.button.syncDataMate": "同步DataMate知识库", @@ -867,11 +837,6 @@ "knowledgeBase.search.placeholder": "搜索知识库名称", "knowledgeBase.filter.source.placeholder": "筛选来源", "knowledgeBase.filter.model.placeholder": "筛选模型", - "knowledgeBase.filter.title": "筛选知识库", - "knowledgeBase.filter.button": "筛选", - "knowledgeBase.card.create": "新建知识库", - "knowledgeBase.card.createDescription": "上传文档,构建专属知识资产", - "knowledgeBase.tag.updatedAt": "更新于{{date}}", "knowledgeBase.source.nexent": "{productName}", "knowledgeBase.source.datamate": "DataMate", "knowledgeBase.source.dify": "Dify", @@ -966,7 +931,6 @@ "document.hint.uploadToCreate": "请选择文件上传以完成知识库创建", "document.hint.noDocuments": "该知识库中暂无文档,请上传文档", "document.table.header.name": "文档名称", - "document.table.header.tags": "标签", "document.table.header.status": "状态", "document.table.header.size": "大小", "document.table.header.date": "上传日期", @@ -3720,6 +3684,8 @@ "aidpKnowledge.kbUnavailable": "不可用", "aidpKnowledge.createKb": "创建知识库", "aidpKnowledge.refresh": "刷新", + "aidpKnowledge.searchPlaceholder": "搜索知识库名称", + "aidpKnowledge.searchEmpty": "没有匹配的知识库", "aidpKnowledge.listEmpty": "暂无知识库", "aidpKnowledge.tagDocs": "文档: {{count}}", "aidpKnowledge.tagChunks": "分块: {{count}}", @@ -3760,15 +3726,6 @@ "aidpKnowledge.docStatusFailed": "失败", "aidpKnowledge.docSize": "大小", "aidpKnowledge.docCreatedAt": "创建时间", - "aidpKnowledge.docActions": "操作", - "aidpKnowledge.download": "下载", - "aidpKnowledge.delete": "删除", - "aidpKnowledge.downloadSuccess": "文件下载已开始", - "aidpKnowledge.downloadFailed": "文件下载失败", - "aidpKnowledge.confirmDeleteDocTitle": "删除文档", - "aidpKnowledge.confirmDeleteDocContent": "确定删除该文档吗?此操作无法撤销。", - "aidpKnowledge.deleteDocSuccess": "文档删除成功", - "aidpKnowledge.deleteDocFailed": "文档删除失败", "aidpKnowledge.noDocuments": "暂无文档", "aidpKnowledge.loadingDocs": "正在加载文档...", "aidpKnowledge.uploadSuccess": "成功上传 {{count}} 个文档", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index b1c1336288..a6fa45e0cf 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -377,10 +377,6 @@ export const API_ENDPOINTS = { kbDetail: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}`, kbDocuments: (id: string) => `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents`, - removeKbDocuments: (id: string) => - `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/remove`, - downloadKbDocument: (id: string) => - `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/documents/download`, models: `${API_BASE_URL}/aidp-mgmt/models`, /** PATCH endpoint for the per-KB in-group permission. */ kbPermission: (id: string) => diff --git a/sdk/nexent/scheduler/core.py b/sdk/nexent/scheduler/core.py index f7377a9066..228730bf28 100644 --- a/sdk/nexent/scheduler/core.py +++ b/sdk/nexent/scheduler/core.py @@ -109,6 +109,7 @@ def __init__( self._loop_task: asyncio.Task[None] | None = None self._stop_event = asyncio.Event() self._running: set[asyncio.Task[None]] = set() + self._waiting: set[Hashable] = set() self._recovery_pending = True @property @@ -119,6 +120,25 @@ def is_running(self) -> bool: def active_count(self) -> int: return len(self._running) + @property + def waiting_count(self) -> int: + """Jobs parked on external (e.g. human) input; they still hold leases.""" + return len(self._waiting) + + def mark_waiting(self, job_id: Hashable, *, waiting: bool) -> None: + """Flag a claimed job as parked on external input (human decisions). + + A waiting job keeps its executor task and lease alive but no longer + consumes a max_concurrency slot, so hour-long human waits cannot + starve machine execution. The waiting set is bounded only by job + expiration, not by max_concurrency. Must be called on the scheduler + loop thread; worker threads should relay via loop.call_soon_threadsafe. + """ + if waiting: + self._waiting.add(job_id) + else: + self._waiting.discard(job_id) + async def start(self) -> None: if self.is_running: return @@ -153,7 +173,9 @@ async def _run_loop(self) -> None: if self._recovery_pending: await self.store.recover() self._recovery_pending = False - capacity = max(0, self.config.max_concurrency - len(self._running)) + # Waiting jobs still hold executor tasks, but their parked + # human-input waits must not consume execution concurrency. + capacity = max(0, self.config.max_concurrency - (len(self._running) - len(self._waiting))) if capacity: claimed = await self.store.claim_due( self.owner_id, @@ -203,6 +225,7 @@ async def _run_claimed(self, job: ClaimedJob[JobPayload]) -> None: except Exception: logger.exception("Scheduled job failed: job_id=%s", job.job_id) finally: + self._waiting.discard(job.job_id) renewal.cancel() await asyncio.gather(renewal, return_exceptions=True) try: diff --git a/test/backend/services/test_application_execute_attempt.py b/test/backend/services/test_application_execute_attempt.py index cff6064a2e..ed4f462b82 100644 --- a/test/backend/services/test_application_execute_attempt.py +++ b/test/backend/services/test_application_execute_attempt.py @@ -1,7 +1,7 @@ """Unit test for application.py execute_attempt async consumer loop. -Covers the _flush_if_due batch/threshold flush (peek-then-take, begin_emit / -end_emit lifecycle) and the final flush in the ``finally`` block. No real +Covers the _flush_if_due batch/threshold flush (peek-then-drain_and_emit) and +the final flush in the ``finally`` block. No real database or agent — every collaborator is mocked. This lets us exercise the patch lines added in the HITL reorder PR without depending on the full Postgres-backed test suite. @@ -43,7 +43,7 @@ class _Port(RuntimeInteractionPort): def __init__(self, *a, **kw): self._chunk_buffer = [] self._chunk_buffer_lock = threading.Lock() - self._emit_in_flight = threading.Event() + self._emit_lock = threading.Lock() self.service = MagicMock() self.run_id = "run-1" self.tenant_id = "tenant-1" @@ -169,17 +169,12 @@ async def prepare_mock(**kwargs): return fake_info, None with _patched_application(make_port, fake_stream, prepare_mock) as application: - try: - await application.execute_attempt(*_execute_attempt_args()) - except StopAsyncIteration: - # The async consumer loop hit the StopAsyncIteration re-raised - # from the finally block — that's fine, we still ran through - # the entire consumer loop including _flush_if_due + final flush. - pass - except Exception as exc: - pytest.fail(f"execute_attempt raised unexpected {type(exc).__name__}: {exc!r}") + # A normally exhausted stream must NOT leak StopAsyncIteration — it + # used to escape the finally block and fail the claiming scheduler job. + await application.execute_attempt(*_execute_attempt_args()) port = port_ref["p"] + assert calls == [("finish", "failed")], calls total_persisted = sum(len(batch) for batch in port._emits) # Timing-sensitive — the sleep in fake_stream may cause the "final" chunk # to land in either the timed _flush_if_due path or the final-flush path. @@ -188,9 +183,9 @@ async def prepare_mock(**kwargs): f"Expected >=3 chunks persisted, got {total_persisted} batches={port._emits}" ) - # begin_emit must have been paired with end_emit — otherwise we would - # have seen _emit_in_flight still set after the loop. - assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + # Every drain must have released the emit lock — a leak would deadlock + # the worker's flush before its next HITL transaction. + assert not port._emit_lock.locked(), "drain_and_emit leaked the emit lock" # --- exception / finally path coverage --------------------------------------- @@ -264,7 +259,7 @@ def add_chunk(chunk): # The two buffered chunks were flushed by the finally block, before finish. assert port._emits == [["chunk-0", "chunk-1"]], port._emits assert refs["finish_calls"] == ["failed"] - assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + assert not port._emit_lock.locked(), "drain_and_emit leaked the emit lock" async def test_cancelled_error_cancels_scope_and_reraises_without_finish(): diff --git a/test/backend/services/test_runtime_port_chunk_buffer.py b/test/backend/services/test_runtime_port_chunk_buffer.py index e7eb40f087..168485a3e7 100644 --- a/test/backend/services/test_runtime_port_chunk_buffer.py +++ b/test/backend/services/test_runtime_port_chunk_buffer.py @@ -9,11 +9,11 @@ import threading import time -import types from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest +from nexent.core.human_interaction.contracts import AttemptSuspended def _make_port(): @@ -199,7 +199,6 @@ def test_dispatch_calls_flush_before_transaction(): # Interaction path needs payload with questions for the early-return # "already answered" branches — let's use no interaction so we fall # through to the "not suspended → emit STARTED" case. - from nexent.core.human_interaction.contracts import AttemptSuspended # We expect either normal return or AttemptSuspended; both are fine as # long as flush_chunks_until_idle is called before any DB work. @@ -291,17 +290,17 @@ def fake_repo_transaction(*args, **kwargs): # --- in-flight emit and failure recovery -------------------------------------- def test_flush_until_idle_treats_in_flight_emit_as_busy(): - """An empty buffer is NOT idle while an async emit is handing chunks to - the DB thread — flush keeps polling until the emit completes. + """An empty buffer is NOT idle while an async drain holds the emit lock — + flush keeps polling until the drain completes. """ port = _make_port() port.emit_chunks = MagicMock() - port._emit_in_flight.set() + port._emit_lock.acquire() result: dict = {} def clear_soon(): time.sleep(0.06) - port._emit_in_flight.clear() + port._emit_lock.release() threading.Thread(target=clear_soon, daemon=True).start() @@ -318,7 +317,7 @@ def run_flush(): # Without the in-flight guard the flush would settle at ~10ms on the # empty buffer; observing >= 50ms proves it waited for the emit. assert result["elapsed"] >= 0.05, result - assert not port._emit_in_flight.is_set() + assert not port._emit_lock.locked() def test_flush_until_idle_restores_chunks_when_emit_chunks_raises(): @@ -334,7 +333,116 @@ def test_flush_until_idle_restores_chunks_when_emit_chunks_raises(): port.flush_chunks_until_idle(max_wait_ms=200, settle_ms=10) assert port.peek_chunks() == 2 - assert not port._emit_in_flight.is_set(), "begin_emit without end_emit leaked" + assert not port._emit_lock.locked(), "emit lock leaked on failure" + + +# --- SSE ordering race regressions -------------------------------------------- + +def test_flush_waits_for_in_flight_drain_past_deadline(): + """Regression: the hard deadline must NOT fire while an async drain holds + the emit lock, even past max_wait_ms. + + On a loaded server the async drain's run_blocking(emit_chunks) can exceed + 500ms; the old implementation broke out of the flush on the deadline and + wrote the HITL row, so the drained chunks landed after it in DB seq order + (the "form before model output" disorder). The flush must instead wait + for the lock, drain what arrived, and only then return. + """ + port = _make_port() + port._emit_lock.acquire() + result: dict = {} + + def drain_slowly(): + # Simulate an in-flight async drain that finishes after the deadline. + time.sleep(0.6) + port.add_chunk("late-chunk") + port._emit_lock.release() + + threading.Thread(target=drain_slowly, daemon=True).start() + + def run_flush(): + started = time.monotonic() + port.flush_chunks_until_idle(max_wait_ms=100, settle_ms=10) + result["elapsed"] = time.monotonic() - started + + worker = threading.Thread(target=run_flush, daemon=True) + worker.start() + worker.join(timeout=5) + + assert not worker.is_alive(), "flush blocked past the join timeout" + # The flush must have waited for the in-flight drain (0.6s), well past + # its own 100ms deadline, so the late chunks are persisted BEFORE the + # caller proceeds to write its HITL transaction. + assert result["elapsed"] >= 0.6, result + assert port.emit_chunks.call_args.args[0] == ["late-chunk"] + assert not port._emit_lock.locked() + + +def test_concurrent_drain_and_flush_preserve_chunk_order(): + """Regression: take_chunks + emit_chunks must be one atomic critical section. + + With the old design (take outside the lock) a worker flush and the async + drain could drain concurrently and the DB seq assignment followed lock + acquisition order instead of production order. With the shared critical + section, a chunk added earlier can never be persisted later. + """ + + def run_round(): + port = _make_port() + emitted: list[str] = [] + emit_lock = threading.Lock() + + def record(chunks, *, _lock=emit_lock, _out=emitted): + with _lock: + _out.extend(chunks) + + port.emit_chunks = MagicMock(side_effect=record) + total = 100 + + def drainer(stop: threading.Event, *, _port=port): + while not stop.is_set() or _port.peek_chunks(): + _port.drain_and_emit() + + stop = threading.Event() + threads = [threading.Thread(target=drainer, args=(stop,), daemon=True) for _ in range(2)] + for t in threads: + t.start() + for i in range(total): + port.add_chunk(f"c{i}") + stop.set() + for t in threads: + t.join(timeout=5) + assert not t.is_alive(), "drainer thread did not finish" + + assert emitted == [f"c{i}" for i in range(total)], ( + f"chunk order corrupted: {emitted[:10]}..." + ) + assert port.peek_chunks() == 0 + assert not port._emit_lock.locked() + + for _round in range(10): + run_round() + + +def test_drain_and_emit_empty_buffer_is_noop(): + """drain_and_emit on an empty buffer must not call emit_chunks.""" + port = _make_port() + port.drain_and_emit() + port.emit_chunks.assert_not_called() + assert not port._emit_lock.locked() + + +def test_drain_and_emit_restores_chunks_on_failure(): + """A failed DB emit inside drain_and_emit puts the chunks back.""" + port = _make_port() + port.emit_chunks = MagicMock(side_effect=RuntimeError("db down")) + port.add_chunk("x") + + with pytest.raises(RuntimeError, match="db down"): + port.drain_and_emit() + + assert port.peek_chunks() == 1 + assert not port._emit_lock.locked() def test_finish_flush_failure_still_writes_terminal_status(): diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index 676ae6e7a1..ae5c864745 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -9,8 +9,6 @@ - DELETE /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id} (delete) - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Upload (upload docs) - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles (list docs) - - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Remove (remove docs) - - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Download (download doc) - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Channels (ingestion channels) - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/History (all-status file history) - POST /KnowledgeBase/Tenants/{tenant}/Retrieval/FusionSearch (search - preserved from reference) @@ -36,18 +34,16 @@ import argparse import json import logging -import mimetypes import os import time import uuid from pathlib import Path from typing import Any, Dict, List, Literal, Optional -from urllib.parse import quote from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field -from starlette.responses import JSONResponse, StreamingResponse +from starlette.responses import JSONResponse logger = logging.getLogger("aidp_mgmt_mock") logging.basicConfig( @@ -151,16 +147,14 @@ def _seed_initial_data() -> None: # Seed some documents for the FAQ KB so list_docs is non-empty by default. _DOCUMENTS_BY_KB["aidp-kb-faq"] = [ { - "file_uuid": "00000000-0000-4000-8000-000000000001", - "file_ino_no": 1001, + "file_ino_no": "file-faq-001", "file_name": "常见问题汇总.txt", "file_size": 2048, "file_type": "txt", "create_time": 1718000400, }, { - "file_uuid": "00000000-0000-4000-8000-000000000002", - "file_ino_no": 1002, + "file_ino_no": "file-faq-002", "file_name": "troubleshooting.md", "file_size": 4096, "file_type": "md", @@ -227,55 +221,7 @@ def _load_state() -> None: ) -def _ensure_document_uuids() -> None: - """Backfill stable UUIDs for state created before UUID support existed.""" - for kds_id, documents in _DOCUMENTS_BY_KB.items(): - if not isinstance(documents, list): - continue - for document in documents: - if not isinstance(document, dict) or document.get("file_uuid"): - continue - file_ino_no = str(document.get("file_ino_no") or uuid.uuid4()) - document["file_uuid"] = str( - uuid.uuid5(uuid.NAMESPACE_URL, f"mock-aidp:{kds_id}:{file_ino_no}") - ) - - _load_state() -_ensure_document_uuids() -_save_state() - - -def _public_document(document: Dict[str, Any]) -> Dict[str, Any]: - """Return document metadata without any mock-only private fields.""" - return {key: value for key, value in document.items() if not key.startswith("_")} - - -def _find_document(kds_id: str, file_uuid: str) -> Optional[Dict[str, Any]]: - return next( - ( - document - for document in _DOCUMENTS_BY_KB.get(kds_id, []) - if document.get("file_uuid") == file_uuid - ), - None, - ) - - -def _document_content(document: Dict[str, Any]) -> bytes: - """Build deterministic mock content for a document download.""" - return ( - f"Mock AIDP content for {document.get('file_name', 'download')}\n" - ).encode("utf-8") - - -def _content_disposition(filename: str) -> str: - """Build a standard ASCII fallback plus RFC 5987 UTF-8 filename header.""" - ascii_name = "".join( - char if 32 <= ord(char) < 127 and char not in {'"', "\\"} else "_" - for char in filename - ) - return f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quote(filename)}' # ============================================================================= @@ -374,14 +320,6 @@ class FusionSearchRequest(BaseModel): metadata_condition: Optional[MetadataCondition] = None -class RemoveFilesBody(BaseModel): - file_uuids: List[uuid.UUID] = Field(..., min_length=1) - - -class DownloadFileBody(BaseModel): - file_uuid: uuid.UUID = Field(...) - - # ============================================================================= # Auth helper # ============================================================================= @@ -711,16 +649,8 @@ async def upload_documents( for f in files: try: content = await f.read() - file_ino_no = max( - ( - document["file_ino_no"] - for document in _DOCUMENTS_BY_KB.get(kds_id, []) - if isinstance(document.get("file_ino_no"), int) - ), - default=0, - ) + 1 + file_ino_no = f"file-{uuid.uuid4().hex[:12]}" doc = { - "file_uuid": str(uuid.uuid4()), "file_ino_no": file_ino_no, "file_name": f.filename or "unknown", "file_size": len(content), @@ -756,7 +686,6 @@ async def upload_documents( "file_type": doc["file_type"], "file_size": doc["file_size"], "file_ino_no": doc["file_ino_no"], - "file_uuid": doc["file_uuid"], "first_upload_time": doc["create_time"], } for doc in success_docs @@ -789,7 +718,7 @@ def list_documents( ] start = (page - 1) * page_size end = start + page_size - items = [_public_document(doc) for doc in all_docs[start:end]] + items = all_docs[start:end] # Real AIDP returns `next_link` as the authoritative "more pages exist" # signal. When there are no more docs, next_link is simply absent. @@ -806,89 +735,6 @@ def list_documents( }) -@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Remove") -def remove_documents( - kds_id: str, - body: RemoveFilesBody, - authorization: Optional[str] = Header(default=None), -) -> JSONResponse: - """Remove documents by file UUID and return per-file success/failure lists.""" - _check_auth(authorization) - - if kds_id not in _KNOWLEDGE_BASES: - raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") - - documents = _DOCUMENTS_BY_KB.setdefault(kds_id, []) - remaining = list(documents) - success_list: List[Dict[str, str]] = [] - failed_list: List[Dict[str, str]] = [] - for raw_file_uuid in body.file_uuids: - file_uuid = str(raw_file_uuid) - matched = next( - (document for document in remaining if document.get("file_uuid") == file_uuid), - None, - ) - if matched is None: - failed_list.append({"file_uuid": file_uuid}) - continue - remaining.remove(matched) - success_list.append({"file_uuid": file_uuid}) - - _DOCUMENTS_BY_KB[kds_id] = remaining - _save_state() - logger.info( - "REMOVE DOCS kds_id=%s total=%d success=%d failed=%d", - kds_id, - len(body.file_uuids), - len(success_list), - len(failed_list), - ) - return JSONResponse(content={ - "summary": { - "total": len(body.file_uuids), - "success": len(success_list), - "failed": len(failed_list), - }, - "success_list": success_list, - "failed_list": failed_list, - }) - - -@app.post(f"{_KB_PREFIX}/{{kds_id}}/KnowledgeFiles/Download") -def download_document( - kds_id: str, - body: DownloadFileBody, - authorization: Optional[str] = Header(default=None), -) -> StreamingResponse: - """Return deterministic binary content for a document download.""" - _check_auth(authorization) - - if kds_id not in _KNOWLEDGE_BASES: - raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") - - file_uuid = str(body.file_uuid) - document = _find_document(kds_id, file_uuid) - if document is None: - raise HTTPException(status_code=404, detail=f"File {file_uuid} not found") - - filename = str(document.get("file_name") or "download") - content = _document_content(document) - content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" - response_headers = { - "Content-Disposition": _content_disposition(filename), - "X-File-Size": str(len(content)), - } - async def content_stream(): - for offset in range(0, len(content), 8 * 1024): - yield content[offset : offset + 8 * 1024] - - return StreamingResponse( - content_stream(), - media_type=content_type, - headers=response_headers, - ) - - # ============================================================================= # Ingestion channels + knowledge-file history # ============================================================================= diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py index bb845e2cba..9d5c6305d8 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_app.py +++ b/test/ext_components/aidp/test_aidp_mgmt_app.py @@ -21,7 +21,6 @@ from typing import Any from unittest.mock import MagicMock, patch -import httpx import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -47,11 +46,6 @@ def _mod(name): nexent_storage_factory = _mod("nexent.storage.storage_client_factory") nexent_storage_factory.create_storage_client_from_config = MagicMock() -services_pkg = _mod("services") -services_pkg.__path__ = [os.path.join(BACKEND_DIR, "services")] -tag_management_service = _mod("services.tag_management_service") -tag_management_service.TagManagementService = MagicMock() - class _MinIOStorageConfig: def __init__(self, **kwargs): @@ -61,7 +55,7 @@ def __init__(self, **kwargs): nexent_storage_factory.MinIOStorageConfig = _MinIOStorageConfig for mod in (nexent_pkg, nexent_utils, nexent_http_mgr, nexent_storage, - nexent_storage_factory, services_pkg, tag_management_service): + nexent_storage_factory): sys.modules.setdefault(mod.__name__, mod) # Register non-prefixed ``database`` / ``database.client`` stubs so that @@ -854,153 +848,6 @@ def count_docs(*_args, **_kwargs): assert response.json()["total_count"] == 1 -# --- Remove/download documents ------------------------------------------- - - -class TestAidpDocumentFileOperations: - def test_remove_forwards_only_uuids(self): - client = _client() - from ext_components.aidp.apps import aidp_mgmt_app - from ext_components.aidp.services import aidp_permission_service - - aidp_result = { - "summary": {"total": 2, "success": 1, "failed": 1}, - "success_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], - "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000002"}], - } - with patch.object( - aidp_permission_service, - "require_permission", - return_value=MagicMock(permission="EDIT"), - ), patch.object( - aidp_mgmt_app, - "remove_aidp_docs_impl", - return_value=aidp_result, - ) as mock_remove: - response = client.post( - "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", - headers=_bearer(), - json={ - "file_uuids": [ - "00000000-0000-4000-8000-000000000001", - "00000000-0000-4000-8000-000000000002", - ] - }, - ) - - assert response.status_code == HTTPStatus.OK - assert response.json() == aidp_result - assert mock_remove.call_args.args[3] == [ - "00000000-0000-4000-8000-000000000001", - "00000000-0000-4000-8000-000000000002", - ] - - def test_remove_requires_file_uuids(self): - client = _client() - from ext_components.aidp.apps import aidp_mgmt_app - - with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: - response = client.post( - "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", - headers=_bearer(), - json={}, - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - mock_remove.assert_not_called() - - def test_remove_requires_standard_file_uuid(self): - client = _client() - from ext_components.aidp.apps import aidp_mgmt_app - - with patch.object(aidp_mgmt_app, "remove_aidp_docs_impl") as mock_remove: - response = client.post( - "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", - headers=_bearer(), - json={"file_uuids": ["uuid-1"]}, - ) - - assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY - mock_remove.assert_not_called() - - def test_download_returns_binary_response(self): - client = _client() - from ext_components.aidp.apps import aidp_mgmt_app - from ext_components.aidp.services import aidp_permission_service - - mock_response = httpx.Response( - 200, - headers={ - "Content-Type": "text/plain", - "Content-Disposition": 'attachment; filename="a.txt"', - "X-File-Size": "5", - }, - content=b"hello", - request=httpx.Request("POST", SERVER_URL), - ) - - async def stream_document(*args): - return mock_response - - with patch.object( - aidp_permission_service, - "require_permission", - return_value=MagicMock(permission="READ_ONLY"), - ), patch.object( - aidp_mgmt_app, - "stream_aidp_doc_impl", - side_effect=stream_document, - ) as mock_download: - response = client.post( - "/aidp-mgmt/knowledge-bases/kb-1/documents/download", - headers=_bearer(), - json={"file_uuid": "00000000-0000-4000-8000-000000000001"}, - ) - - assert response.status_code == HTTPStatus.OK - assert response.content == b"hello" - assert response.headers["content-type"].startswith("text/plain") - assert response.headers["content-disposition"] == 'attachment; filename="a.txt"' - assert response.headers["x-file-size"] == "5" - assert mock_download.call_args.args == ( - SERVER_URL, - API_KEY, - "kb-1", - "00000000-0000-4000-8000-000000000001", - ) - assert "x-file-name" not in response.headers - - def test_remove_does_not_invalidate_cache_when_no_file_succeeds(self): - client = _client() - from ext_components.aidp.apps import aidp_mgmt_app - from ext_components.aidp.services import aidp_permission_service - - aidp_result = { - "summary": {"total": 1, "success": 0, "failed": 1}, - "success_list": [], - "failed_list": [{"file_uuid": "00000000-0000-4000-8000-000000000001"}], - } - with patch.object( - aidp_permission_service, - "require_permission", - return_value=MagicMock(permission="EDIT"), - ), patch.object( - aidp_mgmt_app, - "remove_aidp_docs_impl", - return_value=aidp_result, - ), patch.object(aidp_mgmt_app, "invalidate_aidp_kb_detail_cache") as mock_kb_cache, patch.object( - aidp_mgmt_app, "invalidate_aidp_doc_count_cache" - ) as mock_count_cache: - response = client.post( - "/aidp-mgmt/knowledge-bases/kb-1/documents/remove", - headers=_bearer(), - json={"file_uuids": ["00000000-0000-4000-8000-000000000001"]}, - ) - - assert response.status_code == HTTPStatus.OK - mock_kb_cache.assert_not_called() - mock_count_cache.assert_not_called() - # --- Models list (auth only, no per-KB permission) ------------------------ diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py index 46decb8328..4fe5d35fce 100644 --- a/test/ext_components/aidp/test_aidp_service.py +++ b/test/ext_components/aidp/test_aidp_service.py @@ -3,7 +3,7 @@ import os import sys from types import ModuleType -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import httpx import pytest @@ -1201,16 +1201,6 @@ def _setup_mock_client(aidp_service_module, method="get", response=None, side_ef return mock_client -def _setup_mock_async_client(aidp_service_module, response=None, side_effect=None): - """Create and wire an async mock client into the service module manager.""" - mock_client = MagicMock() - mock_client.send = AsyncMock(side_effect=side_effect, return_value=response) - mock_manager = MagicMock() - mock_manager.get_async_client.return_value = mock_client - aidp_service_module.http_client_manager = mock_manager - return mock_client - - def _make_http_error(status_code, method="GET"): """Create an httpx.HTTPStatusError with given status code.""" request = httpx.Request(method, "http://127.0.0.1:30081") @@ -2115,7 +2105,7 @@ def test_invalid_config(self, aidp_service_module, server_url, api_key): def test_success_normalizes_docs(self, aidp_service_module): mock_resp = _make_success_response({ "value": [ - {"name": "doc1", "file_uuid": "uuid-1", "first_upload_time": 1700000000}, + {"name": "doc1", "first_upload_time": 1700000000}, {"name": "doc2", "create_time": 1700100000, "update_time": 1700200000}, ], "total_count": 2, @@ -2132,7 +2122,6 @@ def test_success_normalizes_docs(self, aidp_service_module): assert len(result["value"]) == 2 # Normalization adds created_at / updated_at assert result["value"][0]["created_at"] is not None - assert result["value"][0]["file_uuid"] == "uuid-1" assert result["value"][1]["updated_at"] is not None def test_success_non_list_value_not_normalized(self, aidp_service_module): @@ -2202,147 +2191,6 @@ def test_json_parse_value_error(self, aidp_service_module): assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR -# --------------------------------------------------------------------------- -# remove_aidp_docs_impl / download_aidp_doc_impl tests -# --------------------------------------------------------------------------- -class TestAidpDocumentFileOperations: - def test_remove_sends_uuid_array_and_preserves_partial_result( - self, aidp_service_module - ): - expected = { - "summary": {"total": 2, "success": 1, "failed": 1}, - "success_list": [{"file_uuid": "uuid-1"}], - "failed_list": [{"file_uuid": "uuid-2"}], - } - mock_client = _setup_mock_client( - aidp_service_module, - method="post", - response=_make_success_response(expected), - ) - - result = aidp_service_module.remove_aidp_docs_impl( - "http://127.0.0.1:30081", - "jwt-token", - "kb-1", - ["uuid-1", "uuid-2"], - ) - - assert result == expected - call = mock_client.post.call_args - assert call.args[0].endswith( - "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Remove" - ) - assert call.kwargs["json"] == {"file_uuids": ["uuid-1", "uuid-2"]} - - def test_remove_maps_request_error(self, aidp_service_module): - request = httpx.Request("POST", "http://127.0.0.1:30081") - _setup_mock_client( - aidp_service_module, - method="post", - side_effect=httpx.RequestError("network down", request=request), - ) - - with pytest.raises(AppException) as exc_info: - aidp_service_module.remove_aidp_docs_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] - ) - assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR - - def test_remove_maps_invalid_json(self, aidp_service_module): - mock_response = _make_success_response({}) - mock_response.json.side_effect = ValueError("bad json") - _setup_mock_client(aidp_service_module, method="post", response=mock_response) - - with pytest.raises(AppException) as exc_info: - aidp_service_module.remove_aidp_docs_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] - ) - assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR - - @pytest.mark.parametrize("status_code", [401, 403, 500]) - def test_remove_maps_upstream_http_errors(self, aidp_service_module, status_code): - _setup_mock_client( - aidp_service_module, - method="post", - side_effect=_make_http_error(status_code, "POST"), - ) - - with pytest.raises(AppException) as exc_info: - aidp_service_module.remove_aidp_docs_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", ["uuid-1"] - ) - expected_code = ( - ErrorCode.AIDP_AUTH_ERROR - if status_code in (401, 403) - else ErrorCode.AIDP_SERVICE_ERROR - ) - assert exc_info.value.error_code == expected_code - - @pytest.mark.asyncio - async def test_download_streams_binary_content_and_headers(self, aidp_service_module): - mock_response = httpx.Response( - 200, - headers={ - "Content-Type": "text/plain", - "Content-Disposition": 'attachment; filename="a.txt"', - "X-File-Size": "16", - }, - content=b"downloaded bytes", - request=httpx.Request("POST", "http://127.0.0.1:30081"), - ) - mock_client = _setup_mock_async_client(aidp_service_module, response=mock_response) - - response = await aidp_service_module.stream_aidp_doc_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" - ) - chunks = [chunk async for chunk in response.aiter_bytes()] - - assert b"".join(chunks) == b"downloaded bytes" - assert response.headers["Content-Type"] == "text/plain" - assert response.headers["Content-Disposition"] == 'attachment; filename="a.txt"' - assert response.headers["X-File-Size"] == "16" - request = mock_client.build_request.call_args - assert request.args[0] == "POST" - assert request.args[1].endswith( - "/KnowledgeBase/Tenants/aidp/KnowledgeBases/kb-1/KnowledgeFiles/Download" - ) - assert request.kwargs["json"] == {"file_uuid": "uuid-1"} - await response.aclose() - assert mock_response.is_closed - - @pytest.mark.asyncio - async def test_download_maps_request_error(self, aidp_service_module): - request = httpx.Request("POST", "http://127.0.0.1:30081") - _setup_mock_async_client( - aidp_service_module, - side_effect=httpx.RequestError("network down", request=request), - ) - - with pytest.raises(AppException) as exc_info: - await aidp_service_module.stream_aidp_doc_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" - ) - assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR - - @pytest.mark.asyncio - async def test_download_maps_upstream_http_error(self, aidp_service_module): - response = httpx.Response( - 404, - json={"error": "file not found"}, - request=httpx.Request("POST", "http://127.0.0.1:30081"), - ) - _setup_mock_async_client( - aidp_service_module, - response=response, - ) - - with pytest.raises(AppException) as exc_info: - await aidp_service_module.stream_aidp_doc_impl( - "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" - ) - assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR - - # --------------------------------------------------------------------------- # list_aidp_models_impl tests # --------------------------------------------------------------------------- diff --git a/test/sdk/scheduler/test_core.py b/test/sdk/scheduler/test_core.py index 4c51a65e64..e7994efe99 100644 --- a/test/sdk/scheduler/test_core.py +++ b/test/sdk/scheduler/test_core.py @@ -136,6 +136,45 @@ async def execute(job, lease): assert observed_lost == [True] +@pytest.mark.asyncio +async def test_waiting_jobs_do_not_consume_concurrency(): + store = MemoryLeaseStore([ClaimedJob(1, {"id": 1}), ClaimedJob(2, {"id": 2})]) + started = [] + first_running = asyncio.Event() + second_running = asyncio.Event() + gate = asyncio.Event() + + async def execute(job, lease): + started.append(job.job_id) + # Both executors must stay alive: an instantly-completing job 2 would + # race its done-callback against the active_count assertion below. + if job.job_id == 1: + first_running.set() + else: + second_running.set() + await gate.wait() + + scheduler = LeaseScheduler(store, execute, _config(max_concurrency=1), owner_id="scheduler-a") + await scheduler.start() + await _wait_until(first_running.is_set) + + # With job 1 executing, the single concurrency slot is fully consumed. + assert scheduler.active_count == 1 + assert 2 not in started + + # Job 1 parks on human input: its slot frees for job 2 without finishing. + scheduler.mark_waiting(1, waiting=True) + await _wait_until(second_running.is_set) + assert scheduler.active_count == 2 + assert scheduler.waiting_count == 1 + + gate.set() + await scheduler.stop() + + # Completion clears the waiting flag even without an explicit reset. + assert scheduler.waiting_count == 0 + + @pytest.mark.asyncio async def test_scheduler_recovers_after_transient_claim_failure(): store = MemoryLeaseStore([ClaimedJob(1, {"id": 1})]) From 8ca1aee5e178648bedae16eeac9d374fb52bc208 Mon Sep 17 00:00:00 2001 From: JeffWu <45140512+jeffwu-1999@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:33:32 +0800 Subject: [PATCH 4/5] merge(main): merge v2.6.1 hotfixes from hotfix/v2.6.1 (#3993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Fix(evaluation): run trials in runtime service (#3954) * fix(evaluation): run trials in runtime service Route trial evaluations through the authenticated Config-to-Runtime proxy and use Config's manager only for creation-stage preparation. Keep Agent execution and evaluator scoring in Runtime. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub config thread manager Keep pure-logic service import tests aligned with the Config and Runtime thread-manager split. Co-authored-by: Codex Generated-by: gpt-5 * test(evaluation): stub runtime jwt helper * test(evaluation): cover trial proxy error paths * Fix/override delete (#3958) * Fix: override dialog only shows override values, not model defaults (deleted params no longer reappear) * Fix: custom param deletion persists (null markers), per-agent capacity overrides take effect, and edit-dialog connectivity probe uses stored api_key * Fix: rename ModelRequest.model_id to probe_model_id - model_dump() is spread into INSERT column lists, so a model_id field injected an explicit NULL primary key and broke model creation * Fix: move probe_model_id to a dedicated ModelProbeRequest subclass - ModelRequest.model_dump() is spread into INSERT column lists, so any non-column field breaks model creation (Unconsumed column names) * Fix: editing/adding a model no longer steals the occupied default-model slot - persistCustomLocalConfig now only writes the slot when it is empty (onboarding) or the submitted model already occupies it * Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots) * Revert "Fix: remove persistCustomLocalConfig - the frontend-cached-config guard could still steal an occupied default slot when the cache was stale/empty. Default-slot writes now come only from the server (create-time backfill for empty/dangling slots)" This reverts commit 2376aa50ccb0f170e5412a14c5aee33798ca1f06. * Fix: VLM connectivity probe never found the local test image - the gateway adapter's relative dirname chain resolved two levels short of the package root, so every probe silently fell back to a public URL that is unreachable in offline deployments. Anchor both probe copies on nexent.__file__ so the path survives module moves. --------- Co-authored-by: ljy * cherry-pick: HITL bugfixes from PR #3948 into hotfix/v2.6.1 (#3959) * Bubfix: guarantee event order, harden chunk buffer, SSE-subscribe controller, and break adapter on terminal human_run (#3948) * fix(hitl): preserve correct event ordering between observer chunks and human_interaction requests Root cause: the worker thread writes human_interaction events synchronously via SQLAlchemy in ask_user, while model_output_thinking/parse observer messages flow through the async consumer and are flushed only on a batched threshold (32 chunks or 250ms). When the worker suspends before that flush fires, human_interaction gains a lower event_seq number than the already-buffered observer chunks, causing the SSE replay stream to show them in the wrong order. Fix: replace the plain async-for consumer loop with a manual asyncio.wait iterator using a 50ms timeout. Once the worker finishes producing model output (i.e. right before ask_user), the loop times out and flushes any buffered observer chunks to the DB first, guaranteeing they precede the subsequent human_interaction row. Empty queue idle periods are essentially zero-cost; overall DB write frequency stays on par with the original. * fix(hitl): guarantee observer chunks precede human_interaction in DB event order When the agent invokes ask_user, two independent write paths caused the human_interaction row to be persisted BEFORE model_output_thinking / parse chunks, breaking the SSE replay ordering: the worker thread writes HITL events synchronously via SQLAlchemy, while observer messages flow through the async consumer which only flushes on a batched threshold. Fix: introduce a thread-safe shared chunk buffer on RuntimeInteractionPort (port.add_chunk / port.take_chunks). The async consumer pushes every processed chunk there; the worker thread calls flush_chunks_until_idle() before dispatching any HITL event — it polls the shared buffer and waits for the async loop to drain the observer queue (20ms idle window, 500ms max wait), then persists every chunk in its own transaction. This guarantees chunk event_seq < human_interaction event_seq regardless of async scheduling latency. Also fix ImportError: openai 2.50 removed the httpx2 module. OpenAIModel now falls back from httpx2 to httpx at import time. * test(hitl): cover shared chunk buffer, flush_chunks_until_idle, and httpx2-fallback paths Add unit tests for the RuntimeInteractionPort thread-safe chunk buffer and the flush_chunks_until_idle poll loop that guarantees observer chunks precede human_interaction events in DB order. All five HITL entry points (dispatch / boundary / receipt / finish / _wait_until_ready) are verified to invoke the idle flush before opening their transaction. Also add two tests for the openai_llm httpx2 → httpx ImportError fallback introduced to support openai >= 2.50 where the httpx2 shim was removed: one covers the fallback path, one confirms httpx2 still wins when present. * fix(hitl): address 4 review comments — hard deadline, emit_in_flight, peek_chunks, try/except safety Fix 4 real issues flagged by github-code-review: 1. Non-resettable hard_deadline in flush_chunks_until_idle — previously reset on every drain, meaning a model that kept producing chunks could stall the worker forever. deadline is now computed once at entry and the sleep call clips to hard_deadline - now. 2. _emit_in_flight Event bridges the async emit path and the worker's idle poll. Without this, buffer-empty = 'persisted' was confused with buffer-empty = 'taken for emit but still in run_blocking queue'. The worker now checks both 'buffer empty for settle_ms' AND 'no emit in flight' before deciding the async side is truly idle. 3. peek_chunks() replaces the take-put-back pattern in _flush_if_due. Previously the async loop drained the buffer, decided it was not yet due, then put everything back. That transiently-empty window (16 us normally, arbitrarily long under GIL/GC/preemption) was enough for the worker's 20 ms poll to mis-fire. We now peek (read count, no drain) and only take_chunks when we actually intend to persist. 4. emit_chunks wrapped in try/except that puts drained chunks back into the shared buffer before re-raising, and finish() wraps its flush call in try/except: pass. Guarantees (a) no chunk loss on DB failure and (b) the terminal human_run row is always written even if the flush step fails. Tests added: - 12 pure-mock unit tests in test_runtime_port_chunk_buffer.py cover hard_deadline, _emit_in_flight, peek_chunks, begin_emit/end_emit, try/except path, and every HITL entry-point's flush-before-transaction. - 1 async execute_attempt integration test in new test_application_execute_attempt.py drives the full consumer loop through _flush_if_due (peek → take → begin/end_emit) and the final flush, verifying that every patch line added in application.py is hit. * perf(hitl): stop polling while SSE stream is active, fallback to 5s when disconnected When isRunning=true the EventSource already pushes human_interaction and human_execution events in real time — the 1.5s polling loop duplicated that work, hitting the DB and re-rendering the frontend for every tick. Disable polling entirely while the SSE stream is alive, and drop to 5s intervals only when the stream is closed (e.g. page load before the first run, or after a run finishes) so we can still discover WAITING_HUMAN requests that were created while the client was disconnected. Add isRunning to the useEffect dependency array so the polling cadence resets immediately when the SSE connection state changes. * perf(hitl): replace polling with SSE subscription and move snapshot off the write path Frontend — /conversation polling → /{run_id}/events SSE: - Replace the 5s conversation snapshot polling with a native EventSource subscription to the backend's /{run_id}/events SSE stream. Discovery is now one-shot: conversationId change and the agent stream pause (isRunning true→false), the exact moment a HITL run is most likely to exist. The SSE stream then keeps run state live with native auto-reconnect. - Add dual guards inside refresh() to absorb the thundering herd from adapter.onHumanInteractionEvent (fires once per HITL SSE chunk) plus our own SSE effect: (1) in-flight dedupe — one snapshot absorbs all concurrent callers and returns cached state; (2) 3s minimum interval so bursts after the in-flight resolves do not immediately re-hit DB. - Use a runRef mirror so refresh() stays stable and downstream effects do not re-run on every snapshot. - Detect terminal status inside SSE onmessage and proactively es.close() to prevent EventSource from reconnecting forever against COMPLETED runs. Backend — snapshot off the write path: - Add repository.read_only() context manager: plain SELECT without WITH FOR UPDATE, no transaction, no flush, no _expire scan. Pure reads must not contend with worker writes on the same row lock. - Add service.light_snapshot() using read_only. Retain snapshot() as a writer-path API for any future lock-held callers. - Route conversation_snapshot, run snapshot endpoint, and both snapshot calls inside stream_run() through light_snapshot. - Move expiration to the writer path: decide() still calls _expire inline before processing each request, and expire_waiting() remains the periodic scheduler sweep. Impact: conversation snapshot calls drop from 12+/min (polling) or 10+/s (burst from adapter + SSE) to at most one every 3s. Each call is now two plain SELECTs instead of a lock-held transaction with a possible write from _expire. Read and write paths are fully decoupled. * fix(hitl): detect terminal human_run in adapter and break stream so isRunning flips false After a HITL run reaches FAILED/COMPLETED, Assistant-UI's isRunning stayed true — the stop button remained visible and new messages went into the queue buffer instead of being sent normally. The root cause is that isRunning is driven entirely by the ChatModelRun generator lifetime, which only returns when the backend SSE HTTP connection closes (reader.read() -> done=true). The backend stream_run loop can hang on heartbeat even after the run is terminal when the SSE was opened during WAITING_HUMAN with attempt_active=true: the break condition requires both cursor >= event_seq AND (terminal status OR WAITING_HUMAN with attempt_active=false and empty rows). If continueHitl fires mid-flight with a stale after_event, the cursor never catches up, so the SSE stays alive forever and the generator never returns. Stop depending on the backend closing first. Inside the adapter's SSE chunk loop, detect a terminal human_run event (status in COMPLETED, FAILED, STOPPED, EXPIRED), set a hitlTerminal flag, break the inner for-loop, and let the outer while-loop exit via the same flag on the next iteration. Assistant-UI sees the generator return and flips isRunning false immediately. Only affects HITL streams — the normal non-HITL agent path never emits human_run events so this branch is never taken. * test(hitl): update mock from snapshot to light_snapshot after read-path refactor test_human_interaction_app.py still mocked service.snapshot after commit 288ae4e69 moved conversation_snapshot and the run snapshot endpoint to service.light_snapshot (read-only path, no lock, no _expire). The fixture return_value and the two assert_called_once_with/assert_not_called assertions all referenced the old method name, causing CI to fail because MagicMock.snapshot was never called. * test(hitl): raise diff coverage above the 90% merge gate Codecov reported 70.43% patch coverage (target 90%) because new error and race paths in the HITL changes had no tests. Add mocked unit tests for: leftover chunk flush in execute_attempt's finally block before the failed finish, CancelledError scope and stop-event fallbacks, RunTerminated finish race, recovery-required outcome, and chunk iterator aclose failure tolerance; runtime_port in-flight emit busy detection, chunk restoration when emit_chunks raises, and terminal status persistence on flush failure; light_snapshot/read_only service behavior with signed tenant and user scoping; and the httpx fallback when openai._base_client.httpx2 is absent. Measured locally with CI-equivalent per-file pytest isolation: patch coverage 202/202 = 100%. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * style(hitl): unify comment style across HITL changes Merge explanatory inline comments into docstrings, keep single-line comments for inline notes, convert TypeScript block notes to JSDoc, and drop banner/separator lines. Comment-level changes only, no behavior change. * refactor(hitl-test): dedupe fake port setup to satisfy SonarCloud duplication gate SonarCloud failed the quality gate with new_duplicated_lines_density=5.2% (threshold 3%), caused solely by test_application_execute_attempt.py: the inline _Port stub in the flush test and the one in _run_execute_attempt duplicated ~69 lines (2 CPD blocks, 14.4% file density). Extract a shared _build_port_class/_make_port_factory plus a _patched_application context manager and _execute_attempt_args so both call sites reuse a single definition; drop dead code (last_flush, install/monkeypatches, unused imports) and fix the latent bare-contextmanager NameError by using contextlib.contextmanager. No behavioral change; all 8 tests pass. * fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm openai >= 2.50 removed the httpx2 shim from openai._base_client. The bare import httpx2 causes ImportError in CI and on systems with recent openai versions. This restores the try/except fallback introduced in PR #3948 commit 9521b934 and later accidentally reverted by commit d086da259. * Revert "fix(sdk): restore httpx2 → httpx ImportError fallback in openai_llm" This reverts commit 8e06ce348dfc8c34baf42c6bfa8211883d0a2c61. * 🐛 Bugfix: Fixed an issue where the sandbox container user lacked the permissions to create folders and files. (#3963) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngi… (#3962) * Fix: dispatch ModelEngine provider listing to the dedicated ModelEngineProvider - get_provider_models routed every provider through the OpenAI-compatible adapter, so ModelEngine batch import failed (wrong endpoint path /open/router/v1/models, self-signed cert, custom type taxonomy, missing per-model base_url). The dedicated class existed but was never wired in. * chore: ModelEngine catalog base_url placeholder - preset public URL is wrong for private deployments, placeholder communicates the required /open/router/v1 path format --------- Co-authored-by: ljy * [codex] fix(agent): silently retry transient model errors (#3965) * fix(agent): retry transient model failures silently * fix(model): support OpenAI httpx2 timeout client * test(model): add deterministic OpenAI-compatible mock * fix(agent): keep stream runtime within line budget * Fix: AIDP knowledge base bug fix (#3967) * Fix: editing a ModelEngine model no longer flips ssl_verify to True - the update path only checked api_key emptiness while the create path also exempts open/router URLs (ModelEngine self-signed certs). The edit dialog prefills the real key and always submits it, so any edit silently broke connectivity. Exemption now checks the payload URL with a fallback to the stored record; batch-edit groups get the same protection * refactor: extract MODEL_ENGINE_URL_MARKER constant (SonarCloud S1192) and use a placeholder domain in test fixtures - no behavior change * [fix] enforce explicit CodeAgent termination and silent recovery (#3969) * fix(agent): enforce explicit CodeAgent termination * fix(test): restore CodeAgent CI compatibility * cherry-pick: HITL reliability fixes from PR #3977 into hotfix/v2.6.1 (#3981) * Fix StopAsyncIteration leak in execute_attempt finally block Root cause: when the agent chunk stream exhausted normally, the finally block awaited the already-consumed anext_task, re-raising StopAsyncIteration which was not suppressed by the existing CancelledError handling. The leftover chunk flush was skipped, successful runs were marked as failed, and the claiming scheduler job logged errors. Fix: reset anext_task to None before breaking out of the consumption loop so the finally block skips the await and always reaches the leftover flush and terminal finish() write. Tightened the regression test to assert that a normally exhausted stream does not leak StopAsyncIteration and that finish() is called. Also deduplicated the two _Port stub classes via a shared factory to satisfy the SonarCloud new_duplicated_lines_density gate. * Fix HITL form not appearing until page refresh Root cause: the frontend discovery chain rate-limited every refresh() with a 3s min interval and in-flight coalescing, silently dropping the critical human_run/human_interaction events that follow an ask_user suspension. The run event stream goes quiet afterwards, so nothing re-triggered the snapshot and the form only appeared after a manual page reload. Fix: refresh() now takes a force flag that bypasses the throttle; force callers arriving while a snapshot is in flight are re-run via a trailing refreshRef invocation instead of being dropped. SSE human_run/human_interaction/human_decision/human_execution messages and the chat-adapter onHumanInteractionEvent callback now force refresh. * Fix SSE chunk/HITL event ordering race under real server load Root cause: chunk persistence and HITL event writes ran in two threads (async consumer via run_blocking on the control-io lane, worker thread synchronously) with seq assigned at DB row-lock acquisition time. Two race windows reordered messages on loaded servers but never locally: (1) flush_chunks_until_idle's 500ms hard deadline fired while the async drain was still in flight, so the HITL row committed before chunks produced earlier (form appearing before model output); (2) worker emit_chunks and the async _flush_if_due drained concurrently without mutual exclusion, so seq order followed lock acquisition instead of production order. Fix: replace the begin_emit/end_emit Event with a shared threading.Lock and move take_chunks+emit_chunks into one atomic critical section (drain_and_emit) used by both the async consumer and the worker flush. The hard deadline may now only fire once the lock is free, guaranteeing in-flight drains commit before the caller writes its HITL transaction. Added regressions: flush waiting for an in-flight drain past its deadline, and concurrent drains preserving chunk production order. * fix(hitl): recover pending form when SSE delivery stalls silently Root cause: form discovery relied solely on a single EventSource plus refresh() with no fallback. A half-open connection (e.g. hung dev proxy) never raises an error event or reconnects, so human_interaction events are lost until a manual page refresh. A hung snapshot fetch could also keep refreshInFlight stuck forever, silently dropping every later refresh, including forced ones. Changes: - Poll the read-only snapshot every 5s while a run is active; the tick shares the refresh throttle and in-flight guard, so it adds no load while SSE delivery is healthy and discovers a pending form within 5s when the stream stalls - Add a 15s AbortSignal timeout to human-interaction client requests so a hung fetch releases the in-flight guard instead of bricking it - Wrap the human_run chunk JSON.parse in the chat adapter with try/catch: the stream loop has a finally but no catch, so a malformed payload would silently kill the whole chat stream read loop * fix(hitl): stop parked human-input waits from consuming scheduler slots Root cause: while a run waits for a human decision, its executor task parks inside _wait_until_ready and the lease renewal loop keeps the lease alive, so the run occupies one HITL_MAX_CONCURRENCY slot for up to HITL_WAIT_SECONDS (default 24h). With HITL enabled every non-debug chat is dispatched through this scheduler, so two unattended forms filled the default concurrency of 2 and froze all conversations: new agent/run streams only emitted heartbeats because READY runs were never claimed. Fix: add LeaseScheduler.mark_waiting so executors can flag themselves as parked on external input. Slot capacity is now max_concurrency minus executing jobs only (running minus waiting), and the waiting flag is cleared in the job's finally block. RuntimeInteractionPort relays enter/exit of _wait_until_ready through a wait_reporter callback, covering resume, termination and lease loss paths. The reporter degrades safely: a stale SDK copy without mark_waiting falls back to slot-consuming waits, and call_soon_threadsafe is wrapped in a lambda because it does not forward keyword arguments. Config: raise the env example defaults from 24h to 1h waits and concurrency 2 to 100, since waiting runs no longer consume execution slots. Tests: new test_waiting_jobs_do_not_consume_concurrency; scheduler suite 14/14, HITL service 12 passed, runtime and app suites 43/43. * test(scheduler): fix flaky waiting-concurrency assertion on fast event loops Root cause: job 2's executor completed instantly after appending to started, so its done-callback could discard it from _running before the active_count == 2 assertion ran. On Linux CI the event loop schedules that callback first, making the test fail intermittently. Fix: both executors now park on the shared gate via separate running events, so the assertion observes a stable running set instead of a transient window. * Fix: drop unrelated AIDP interface refactor from the AIDP knowledge base fix (#3980) The AIDP knowledge base fix reached hotfix/v2.6.1 through PR #3967, which also carried two unrelated upstream changes that this release branch never had: - #3909 Knowledge base interface optimization (AIDP UI refactor) - #3930 support AIDP knowledge file deletion and download Both are removed here so the release line keeps only the bug fix. - Restore the AIDP frontend components to their pre-refactor layout and drop the helper modules only the refactor used: AidpKnowledgeBaseModalParts, useAidpGroupOptions, aidpUploadUtils. - Drop the #3930 document remove/download endpoints from services/api.ts and the AIDP translations that only those screens referenced. - Keep the fix itself unchanged: knowledge-base scoped Channels and KnowledgeFiles/History paths, all-status document listing, keyword search, status labels (UPLOADING / PROCESSING / EXTRACTING) and the upload-triggered polling. Verified: - pytest test/ext_components/aidp -q -> 583 passed - frontend `npm run type-check` (tsc --noEmit) -> no errors * fix(agent): accept reasoning-prefixed code actions (#3990) * refactor: remove human interaction features and related configurations (#3988) * refactor: remove human interaction features and related configurations * refactor: remove human interaction features and related configurations --------- Co-authored-by: cj2026-bit <647646783@qq.com> Co-authored-by: lijiayang619 <1170349871@qq.com> Co-authored-by: ljy Co-authored-by: bernard1234 <840646206@qq.com> Co-authored-by: panyehong <91180085+YehongPan@users.noreply.github.com> Co-authored-by: Jason Wang <56037774+JasonW404@users.noreply.github.com> Co-authored-by: gs-aion Co-authored-by: Dallas98 <40557804+Dallas98@users.noreply.github.com> Co-authored-by: chase --- backend/apps/agent_app.py | 27 - backend/apps/human_interaction_app.py | 91 - backend/apps/northbound_app.py | 9 +- backend/apps/northbound_base_app.py | 2 - .../apps/northbound_human_interaction_app.py | 100 -- backend/apps/runtime_app.py | 11 - backend/consts/const.py | 6 - backend/consts/model.py | 15 +- backend/database/conversation_db.py | 70 +- backend/database/db_models.py | 195 --- backend/database/human_interaction_db.py | 327 ---- backend/database/human_interaction_models.py | 5 - backend/management/services/agent/run.py | 65 +- backend/prompts/utils/prompt_generate_en.yaml | 2 +- backend/prompts/utils/prompt_generate_zh.yaml | 2 +- backend/pyproject.toml | 1 - .../conversation_management_service.py | 2 + .../services/human_interaction/__init__.py | 1 - .../services/human_interaction/application.py | 419 ----- backend/services/human_interaction/crypto.py | 28 - backend/services/human_interaction/models.py | 48 - .../services/human_interaction/persistence.py | 100 -- .../human_interaction/runtime_port.py | 398 ----- backend/services/human_interaction/service.py | 309 ---- backend/services/human_interaction/stream.py | 44 - backend/services/northbound_service.py | 2 - backend/services/runtime_proxy_service.py | 46 - backend/utils/agent_stream_utils.py | 6 +- backend/utils/context_utils.py | 12 +- deploy/docker/deploy.sh | 30 - deploy/env/.env.example | 12 - deploy/env/hitl.env.example | 8 - deploy/k8s/deploy.sh | 36 +- .../nexent-common/templates/configmap.yaml | 6 - .../nexent-common/templates/secrets.yaml | 4 - .../nexent/charts/nexent-common/values.yaml | 7 - .../v2.6.1_001_remove_human_interaction.sql | 8 + .../integration-out/northbound-api.md | 109 +- .../human-in-the-loop-implementation.md | 217 +-- .../integration-out/northbound-api.md | 111 +- .../conversation-thread-list-adapter.tsx | 32 +- .../adapter/remote-chat-model-adapter.ts | 162 +- .../newchat/assistant-ui/composer.tsx | 119 +- .../[locale]/newchat/assistant-ui/thread.tsx | 14 +- .../components/clarification-message-card.tsx | 96 ++ frontend/app/[locale]/newchat/page.tsx | 111 +- .../[locale]/newchat/utils/ordinary-send.ts | 68 + .../interaction/clarification-card.tsx | 50 +- frontend/e2e/clarification-message.spec.ts | 431 +++++ .../HumanInteractionCards.tsx | 254 --- .../QueuedRunMessageStrip.tsx | 142 -- .../humanInteraction/UserGuidanceMessage.tsx | 33 - .../humanInteraction/clarification.ts | 45 - frontend/features/humanInteraction/client.ts | 102 -- .../humanInteraction/guidanceMessage.ts | 58 - frontend/features/humanInteraction/index.ts | 5 - .../humanInteraction/runMessageQueue.ts | 107 -- .../useHumanInteractionController.ts | 421 ----- .../humanInteraction/useRunMessageQueue.tsx | 195 --- frontend/lib/clarification.ts | 236 +++ frontend/public/locales/en/common.json | 13 +- frontend/public/locales/zh/common.json | 13 +- frontend/services/conversationService.ts | 11 +- frontend/tests/guidanceMessage.test.ts | 46 - frontend/tests/humanClarification.test.ts | 132 +- .../humanInteractionCardIntegration.test.ts | 69 - frontend/tests/ordinarySend.test.ts | 111 ++ frontend/tests/reasoningAccumulator.test.ts | 43 +- frontend/tests/runMessageQueue.test.ts | 271 --- frontend/types/clarification.ts | 54 + frontend/types/conversation.ts | 1 + sdk/nexent/core/agents/agent_model.py | 3 - .../clarification.py | 100 +- sdk/nexent/core/agents/context/formatting.py | 4 +- sdk/nexent/core/agents/core_agent.py | 378 +++-- sdk/nexent/core/agents/nexent_agent.py | 31 +- sdk/nexent/core/agents/output_protocol.py | 402 ++++- sdk/nexent/core/agents/run_agent.py | 20 +- sdk/nexent/core/concurrency/cancellation.py | 4 + sdk/nexent/core/human_interaction/__init__.py | 5 - sdk/nexent/core/human_interaction/codec.py | 50 - .../core/human_interaction/contracts.py | 40 - sdk/nexent/core/human_interaction/executor.py | 121 -- .../core/human_interaction/live_runtime.py | 127 -- sdk/nexent/core/human_interaction/runtime.py | 335 ---- sdk/nexent/core/utils/observer.py | 2 + test/backend/app/test_agent_app.py | 24 - test/backend/app/test_agent_legacy_fields.py | 31 + .../backend/app/test_human_interaction_app.py | 95 -- test/backend/app/test_northbound_app.py | 24 +- test/backend/app/test_northbound_base_app.py | 4 - .../test_northbound_human_interaction_app.py | 150 -- test/backend/database/test_conversation_db.py | 4 +- .../test_conversation_history_stopped.py | 106 ++ .../test_human_interaction_persistence.py | 267 --- .../test_agent_model_attempt_persistence.py | 27 +- test/backend/services/test_agent_service.py | 9 +- .../test_application_execute_attempt.py | 416 ----- .../services/test_human_interaction.py | 1508 ----------------- .../test_human_interaction_snapshot.py | 150 -- .../services/test_human_interaction_stream.py | 120 -- .../services/test_northbound_service.py | 22 +- .../test_runtime_port_chunk_buffer.py | 464 ----- .../services/test_runtime_proxy_service.py | 89 - .../core/agents/test_clarification_form.py | 82 +- .../agents/test_clarification_protocol.py | 73 + .../core/agents/test_clarification_runtime.py | 238 +++ .../agents/test_context_manager_assembly.py | 46 +- test/sdk/core/agents/test_core_agent.py | 43 +- .../core/agents/test_core_agent_planning.py | 2 + .../core/agents/test_guardrail_checkpoints.py | 39 + .../agents/test_human_interaction_runtime.py | 248 --- test/sdk/core/agents/test_nexent_agent.py | 2 + ...st_nexent_agent_context_runtime_factory.py | 24 +- test/sdk/core/agents/test_output_protocol.py | 148 +- test/sdk/core/agents/test_run_agent_hitl.py | 108 -- 116 files changed, 2953 insertions(+), 9568 deletions(-) delete mode 100644 backend/apps/human_interaction_app.py delete mode 100644 backend/apps/northbound_human_interaction_app.py delete mode 100644 backend/database/human_interaction_db.py delete mode 100644 backend/database/human_interaction_models.py delete mode 100644 backend/services/human_interaction/__init__.py delete mode 100644 backend/services/human_interaction/application.py delete mode 100644 backend/services/human_interaction/crypto.py delete mode 100644 backend/services/human_interaction/models.py delete mode 100644 backend/services/human_interaction/persistence.py delete mode 100644 backend/services/human_interaction/runtime_port.py delete mode 100644 backend/services/human_interaction/service.py delete mode 100644 backend/services/human_interaction/stream.py delete mode 100644 deploy/env/hitl.env.example create mode 100644 deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql create mode 100644 frontend/app/[locale]/newchat/components/clarification-message-card.tsx create mode 100644 frontend/app/[locale]/newchat/utils/ordinary-send.ts create mode 100644 frontend/e2e/clarification-message.spec.ts delete mode 100644 frontend/features/humanInteraction/HumanInteractionCards.tsx delete mode 100644 frontend/features/humanInteraction/QueuedRunMessageStrip.tsx delete mode 100644 frontend/features/humanInteraction/UserGuidanceMessage.tsx delete mode 100644 frontend/features/humanInteraction/clarification.ts delete mode 100644 frontend/features/humanInteraction/client.ts delete mode 100644 frontend/features/humanInteraction/guidanceMessage.ts delete mode 100644 frontend/features/humanInteraction/index.ts delete mode 100644 frontend/features/humanInteraction/runMessageQueue.ts delete mode 100644 frontend/features/humanInteraction/useHumanInteractionController.ts delete mode 100644 frontend/features/humanInteraction/useRunMessageQueue.tsx create mode 100644 frontend/lib/clarification.ts delete mode 100644 frontend/tests/guidanceMessage.test.ts delete mode 100644 frontend/tests/humanInteractionCardIntegration.test.ts create mode 100644 frontend/tests/ordinarySend.test.ts delete mode 100644 frontend/tests/runMessageQueue.test.ts create mode 100644 frontend/types/clarification.ts rename sdk/nexent/core/{human_interaction => agents}/clarification.py (59%) delete mode 100644 sdk/nexent/core/human_interaction/__init__.py delete mode 100644 sdk/nexent/core/human_interaction/codec.py delete mode 100644 sdk/nexent/core/human_interaction/contracts.py delete mode 100644 sdk/nexent/core/human_interaction/executor.py delete mode 100644 sdk/nexent/core/human_interaction/live_runtime.py delete mode 100644 sdk/nexent/core/human_interaction/runtime.py create mode 100644 test/backend/app/test_agent_legacy_fields.py delete mode 100644 test/backend/app/test_human_interaction_app.py delete mode 100644 test/backend/app/test_northbound_human_interaction_app.py create mode 100644 test/backend/database/test_conversation_history_stopped.py delete mode 100644 test/backend/database/test_human_interaction_persistence.py delete mode 100644 test/backend/services/test_application_execute_attempt.py delete mode 100644 test/backend/services/test_human_interaction.py delete mode 100644 test/backend/services/test_human_interaction_snapshot.py delete mode 100644 test/backend/services/test_human_interaction_stream.py delete mode 100644 test/backend/services/test_runtime_port_chunk_buffer.py create mode 100644 test/sdk/core/agents/test_clarification_protocol.py create mode 100644 test/sdk/core/agents/test_clarification_runtime.py delete mode 100644 test/sdk/core/agents/test_human_interaction_runtime.py delete mode 100644 test/sdk/core/agents/test_run_agent_hitl.py diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 2957af85f9..24ef6ddb81 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -61,7 +61,6 @@ check_skill_conflicts_impl, ) from services.prompt_service import generate_guardrail_rules_impl -from services.human_interaction.models import InteractionError from services.knowledge_scope_service import get_agent_knowledge_capabilities from services.agent_draft_permission_service import AgentDraftEditError from services.nl2agent_service import Nl2AgentDraftSaveError, create_nl2agent_stream @@ -154,8 +153,6 @@ async def agent_run_api( authorization=authorization, resume=resume, ) - except InteractionError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc except ForbiddenError as e: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) from e except ValidationError as e: @@ -193,8 +190,6 @@ async def northbound_agent_run_api( tenant_id=tenant_id, skip_user_save=True, ) - except InteractionError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc except UnauthorizedError as exc: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, @@ -280,13 +275,6 @@ async def agent_stop_api(run_id: str, authorization: Optional[str] = Header(None Stop an agent run by conversation ID or ephemeral debug run ID. """ user_id, tenant_id = get_current_user_id(authorization) - from consts.const import HITL_ENABLED - if HITL_ENABLED: - from services.human_interaction.application import get_service - service = get_service() - durable_id = service.repository.latest(tenant_id, user_id, int(run_id), active_only=True) if run_id.isdigit() else None - if durable_id: - service.control(durable_id, tenant_id, user_id, "terminate") return stop_agent_tasks(int(run_id) if run_id.isdigit() else run_id, user_id) @@ -301,22 +289,7 @@ async def northbound_agent_stop_api( """Stop a northbound agent run inside the runtime service.""" try: user_id, tenant_id = verify_internal_runtime_jwt(authorization) - from consts.const import HITL_ENABLED - if HITL_ENABLED: - from services.human_interaction.application import get_service - service = get_service() - durable_id = await run_blocking( - "hitl-service-repository-latest", service.repository.latest, tenant_id, user_id, - conversation_id, active_only=True, lane="control-io", owner=__name__, - ) - if durable_id: - await run_blocking( - "hitl-service-control", service.control, durable_id, tenant_id, user_id, "terminate", - lane="control-io", owner=__name__, - ) return stop_agent_tasks(conversation_id, user_id) - except InteractionError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc except UnauthorizedError as exc: raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, diff --git a/backend/apps/human_interaction_app.py b/backend/apps/human_interaction_app.py deleted file mode 100644 index 921e1340b4..0000000000 --- a/backend/apps/human_interaction_app.py +++ /dev/null @@ -1,91 +0,0 @@ -"""HTTP boundary for owner-scoped human decisions and independent run controls.""" - -from fastapi import APIRouter, Depends, Header, HTTPException, Query -from nexent.core.concurrency import run_blocking - -from consts.const import HITL_ACCEPT_NEW_RUNS, HITL_ENABLED, HITL_TOOL_APPROVAL_ENABLED -from consts.exceptions import UnauthorizedError -from services.human_interaction.application import require_enabled, stream_run -from services.human_interaction.models import DecisionCommand, InteractionError, SteeringCommand -from utils.auth_utils import get_current_user_id - - -async def _user_identity(authorization: str = Header(None)): - return get_current_user_id(authorization) - - -async def _internal_identity(authorization: str = Header(None)): - from utils.auth_utils import verify_internal_runtime_jwt - - try: - return verify_internal_runtime_jwt(authorization) - except UnauthorizedError as exc: - raise HTTPException(status_code=401, detail=str(exc)) from exc - - -async def _call(identity, callback): - user_id, tenant_id = identity - try: - return await run_blocking( - "hitl-callback", callback, require_enabled(), tenant_id, user_id, lane="control-io", - owner=__name__, - ) - except InteractionError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc - - -def _build_router(prefix, identity_dependency, *, include_in_schema=True): - """Share the lifecycle contract while keeping session and internal JWT authentication separate.""" - result = APIRouter(prefix=prefix, tags=["human-interaction"], include_in_schema=include_in_schema) - - @result.get("/capabilities") - async def capabilities(identity=Depends(identity_dependency)): - return {"enabled": HITL_ENABLED, "accept_new_runs": HITL_ACCEPT_NEW_RUNS, - "executor": "linear-json-v1" if HITL_TOOL_APPROVAL_ENABLED else "native-live-v1", "live_resume": True, - "tool_approval_enabled": HITL_TOOL_APPROVAL_ENABLED, - "subagents": not HITL_TOOL_APPROVAL_ENABLED, "attachments": not HITL_TOOL_APPROVAL_ENABLED, - "clarification_max_questions": 5, "clarification_max_cards": 1} - - @result.get("/conversation/{conversation_id}") - async def conversation_snapshot(conversation_id: int, identity=Depends(identity_dependency)): - def read(service, tenant_id, user_id): - run_id = service.repository.latest(tenant_id, user_id, conversation_id) - return service.light_snapshot(run_id, tenant_id, user_id) if run_id else None - return await _call(identity, read) - - @result.get("/{run_id}") - async def snapshot(run_id: str, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: service.light_snapshot(run_id, tenant, user)) - - @result.get("/{run_id}/events") - async def events(run_id: str, after_event: int = Query(0, ge=0), identity=Depends(identity_dependency)): - user_id, tenant_id = identity - try: - return await stream_run(run_id, tenant_id, user_id, after=after_event) - except InteractionError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc - - @result.post("/{run_id}/requests/{request_id}/decisions") - async def decide(run_id: str, request_id: str, command: DecisionCommand, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: - service.decide(run_id, request_id, tenant, user, command)) - - @result.post("/{run_id}/pause") - async def pause(run_id: str, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: service.control(run_id, tenant, user, "pause")) - - @result.post("/{run_id}/steer") - async def steer(run_id: str, command: SteeringCommand, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: service.steer(run_id, tenant, user, command)) - - @result.post("/{run_id}/terminate") - async def terminate(run_id: str, identity=Depends(identity_dependency)): - return await _call(identity, lambda service, tenant, user: service.control(run_id, tenant, user, "terminate")) - - return result - - -router = _build_router("/agent/human-interactions", _user_identity) -internal_router = _build_router( - "/agent/internal/northbound/human-interactions", _internal_identity, include_in_schema=False, -) diff --git a/backend/apps/northbound_app.py b/backend/apps/northbound_app.py index 0dd6c50fa8..7153914d1f 100644 --- a/backend/apps/northbound_app.py +++ b/backend/apps/northbound_app.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse from pydantic import ValidationError as PydanticValidationError +from consts.model import reject_legacy_agent_fields from consts.exceptions import ( ConversationNotFoundError, ForbiddenError, @@ -392,15 +393,10 @@ async def run_chat( } }], ), - enable_hitl: bool = Body( - False, - embed=True, - description="Enable human interaction when supported by the runtime. Cards use type=human_interaction; " - "submit answers through /nb/v1/chat/human-interactions/{run_id}/requests/{request_id}/decisions.", - ), idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"), ): try: + reject_legacy_agent_fields(await request.json()) ctx: NorthboundContext = await _get_northbound_context(request) return await start_streaming_chat( ctx=ctx, @@ -413,7 +409,6 @@ async def run_chat( tool_params=tool_params, model_id=model_id, idempotency_key=idempotency_key, - enable_hitl=enable_hitl, ) except LimitExceededError as e: logging.error(f"Too Many Requests: rate limit exceeded: {str(e)}", exc_info=e) diff --git a/backend/apps/northbound_base_app.py b/backend/apps/northbound_base_app.py index 07c221f385..9c0745dbe9 100644 --- a/backend/apps/northbound_base_app.py +++ b/backend/apps/northbound_base_app.py @@ -30,7 +30,6 @@ from services.runtime_state_service import runtime_state_service from .northbound_app import router as northbound_router from .northbound_knowledge_app import router as northbound_knowledge_router -from .northbound_human_interaction_app import router as northbound_human_interaction_router class A2AServerSettings(BaseModel): @@ -111,7 +110,6 @@ async def thread_capacity(): northbound_app.include_router(northbound_router) northbound_app.include_router(northbound_knowledge_router) -northbound_app.include_router(northbound_human_interaction_router) # ============================================================================= diff --git a/backend/apps/northbound_human_interaction_app.py b/backend/apps/northbound_human_interaction_app.py deleted file mode 100644 index a0f81b9c29..0000000000 --- a/backend/apps/northbound_human_interaction_app.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Northbound HITL controls authenticated by API key and executed by runtime.""" - -from http import HTTPStatus -from uuid import UUID - -from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request -from fastapi.responses import JSONResponse, Response - -from apps.northbound_app import _get_northbound_context -from consts.exceptions import RuntimeServiceTimeoutError, RuntimeServiceUnavailableError, RuntimeUpstreamError -from services.human_interaction.models import DecisionCommand, SteeringCommand -from services.northbound_service import NorthboundContext -from services.runtime_proxy_service import forward_human_interaction, forward_human_interaction_events - -router = APIRouter(prefix="/nb/v1/chat/human-interactions", tags=["northbound-human-interaction"]) - - -async def _forward(ctx, method, path, *, command=None, after_event=None): - try: - if after_event is not None: - response = await forward_human_interaction_events( - path, ctx.user_id, ctx.tenant_id, after_event=after_event, - ) - response.headers["X-Request-Id"] = ctx.request_id - response.headers["X-Accel-Buffering"] = "no" - return response - result = await forward_human_interaction( - method, path, ctx.user_id, ctx.tenant_id, - payload=command.model_dump(mode="json") if command is not None else None, - ) - return JSONResponse( - {"message": "success", "requestId": ctx.request_id, "data": result}, - headers={"X-Request-Id": ctx.request_id}, - ) - except RuntimeUpstreamError as exc: - return Response(content=exc.content, status_code=exc.status_code, headers=exc.headers) - except RuntimeServiceTimeoutError as exc: - raise HTTPException(status_code=HTTPStatus.GATEWAY_TIMEOUT, detail=str(exc)) from exc - except RuntimeServiceUnavailableError as exc: - raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=str(exc)) from exc - - -@router.get("/capabilities") -async def capabilities(ctx: NorthboundContext = Depends(_get_northbound_context)): - """Read the runtime deployment's HITL capabilities.""" - return await _forward(ctx, "GET", "capabilities") - - -@router.get("/conversation/{conversation_id}") -async def conversation_snapshot(conversation_id: int, ctx: NorthboundContext = Depends(_get_northbound_context)): - """Find this API user's latest run in a conversation, including pending cards.""" - return await _forward(ctx, "GET", f"conversation/{conversation_id}") - - -@router.get("/{run_id}") -async def snapshot(run_id: UUID, ctx: NorthboundContext = Depends(_get_northbound_context)): - return await _forward(ctx, "GET", str(run_id)) - - -@router.get("/{run_id}/events") -async def events( - run_id: UUID, - request: Request, - after_event: int = Query(0, ge=0, description="Last consumed SSE event ID; 0 replays the run."), - last_event_id: str | None = Header(None, alias="Last-Event-ID"), - ctx: NorthboundContext = Depends(_get_northbound_context), -): - """Subscribe to the same run after submitting a decision or reconnecting.""" - if last_event_id is not None and "after_event" not in request.query_params: - if not last_event_id.isascii() or not last_event_id.isdigit() or len(last_event_id) > 19: - raise HTTPException(status_code=422, detail="Last-Event-ID must be a non-negative integer") - after_event = int(last_event_id) - return await _forward(ctx, "GET", str(run_id), after_event=after_event) - - -@router.post("/{run_id}/requests/{request_id}/decisions") -async def decide( - run_id: UUID, - request_id: UUID, - command: DecisionCommand, - ctx: NorthboundContext = Depends(_get_northbound_context), -): - """Submit a versioned, idempotent answer or approval to the exact pending card.""" - return await _forward(ctx, "POST", f"{run_id}/requests/{request_id}/decisions", command=command) - - -@router.post("/{run_id}/pause") -async def pause(run_id: UUID, ctx: NorthboundContext = Depends(_get_northbound_context)): - return await _forward(ctx, "POST", f"{run_id}/pause") - - -@router.post("/{run_id}/steer") -async def steer(run_id: UUID, command: SteeringCommand, ctx: NorthboundContext = Depends(_get_northbound_context)): - """Submit new guidance; runtime cancels any pending card before applying it.""" - return await _forward(ctx, "POST", f"{run_id}/steer", command=command) - - -@router.post("/{run_id}/terminate") -async def terminate(run_id: UUID, ctx: NorthboundContext = Depends(_get_northbound_context)): - return await _forward(ctx, "POST", f"{run_id}/terminate") diff --git a/backend/apps/runtime_app.py b/backend/apps/runtime_app.py index b372095c40..146b28a6d3 100644 --- a/backend/apps/runtime_app.py +++ b/backend/apps/runtime_app.py @@ -16,8 +16,6 @@ file_management_runtime_router as file_management_router, ) from apps.skill_app import skill_creator_router -from apps.human_interaction_app import router as human_interaction_router -from apps.human_interaction_app import internal_router as internal_human_interaction_router from consts.const import RUNTIME_THREAD_SHUTDOWN_GRACE_SECONDS from middleware.exception_handler import ExceptionHandlerMiddleware from nexent.core.concurrency import ( @@ -33,9 +31,7 @@ async def start_agent_automation_scheduler(): - from consts.const import HITL_ENABLED from services.agent_automation.scheduler import agent_automation_scheduler - from services.human_interaction.application import get_service, human_run_scheduler from services.startup_recovery_service import recover_runtime_tasks from services.workspace_cleanup_service import cleanup_orphaned_agent_workspaces @@ -49,17 +45,12 @@ async def start_agent_automation_scheduler(): ) cleanup_orphaned_agent_workspaces() await agent_automation_scheduler.start() - if HITL_ENABLED: - get_service() - await human_run_scheduler.start() async def stop_agent_automation_scheduler(): from services.agent_automation.scheduler import agent_automation_scheduler - from services.human_interaction.application import human_run_scheduler await agent_automation_scheduler.stop() - await human_run_scheduler.stop() @asynccontextmanager @@ -125,5 +116,3 @@ async def thread_capacity(): app.include_router(file_management_router) app.include_router(voice_router) app.include_router(skill_creator_router) -app.include_router(human_interaction_router) -app.include_router(internal_human_interaction_router) diff --git a/backend/consts/const.py b/backend/consts/const.py index d21fd8ad39..2760cd853b 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -34,12 +34,6 @@ class VectorDatabaseType(str, Enum): # Data Processing Service Configuration DATA_PROCESS_SERVICE = os.getenv("DATA_PROCESS_SERVICE") RUNTIME_SERVICE_URL = os.getenv("RUNTIME_SERVICE_URL", "http://localhost:5014").rstrip("/") -HITL_ENABLED = os.getenv("HITL_ENABLED", "false").lower() in ("true", "1", "yes") -HITL_ACCEPT_NEW_RUNS = os.getenv("HITL_ACCEPT_NEW_RUNS", "true").lower() in ("true", "1", "yes") -HITL_TOOL_APPROVAL_ENABLED = os.getenv("HITL_TOOL_APPROVAL_ENABLED", "false").lower() in ("true", "1", "yes") -HITL_ENCRYPTION_KEY = os.getenv("HITL_ENCRYPTION_KEY", "") -HITL_WAIT_SECONDS = int(os.getenv("HITL_WAIT_SECONDS", "86400")) -HITL_MAX_CONCURRENCY = int(os.getenv("HITL_MAX_CONCURRENCY", "2")) CLIP_MODEL_PATH = os.getenv("CLIP_MODEL_PATH") TABLE_TRANSFORMER_MODEL_PATH = os.getenv("TABLE_TRANSFORMER_MODEL_PATH") UNSTRUCTURED_DEFAULT_MODEL_INITIALIZE_PARAMS_JSON_PATH = os.getenv( diff --git a/backend/consts/model.py b/backend/consts/model.py index 8f2a9a7872..a71211ca6f 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -825,11 +825,15 @@ class ConversationKnowledgeScopeUpdateRequest(BaseModel): scope: Optional[ConversationKnowledgeScopeRequest] = None +def reject_legacy_agent_fields(value): + """Reject removed execution controls rather than silently starting a new run.""" + if isinstance(value, dict) and {"enable_hitl", "hitl_run_id", "hitl_after_event"}.intersection(value): + raise ValueError("Legacy human interaction fields are no longer supported; send a normal query") + return value + + class AgentRequest(BaseModel): query: str - enable_hitl: bool = False - hitl_run_id: Optional[str] = Field(default=None, min_length=36, max_length=36) - hitl_after_event: int = Field(default=0, ge=0) conversation_id: Optional[int] = None history: Optional[List[HistoryItem]] = None # Complete list of attachment information @@ -855,6 +859,11 @@ class AgentRequest(BaseModel): description="Optional optimistic-lock version for runtime metadata updates", ) + @model_validator(mode="before") + @classmethod + def reject_legacy_execution_controls(cls, value): + return reject_legacy_agent_fields(value) + @field_validator("context_policy") @classmethod def validate_context_policy(cls, value): diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index 3799c6cf0f..197578ec65 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -299,7 +299,7 @@ def persist_assistant_run_batch( Returns: Mapping from unit_index to the generated unit_id. """ - if terminal_status not in {"completed", "failed", "stopped", "waiting_human", "recovery_required"}: + if terminal_status not in {"completed", "failed", "stopped"}: raise ValueError(f"Unsupported assistant terminal status: {terminal_status}") message_id = int(message_id) @@ -2132,11 +2132,58 @@ def save_history_summary( ConversationMessageUnit.unit_id)).scalar_one() +_STOPPED_CONTEXT_NOTE = "上一轮已由用户停止,以下内容为已保存的部分结果。" +_UNANSWERED_CONTEXT_NOTE = "该轮未生成有效回复。" +_PARTIAL_CONTEXT_MAX_CHARS = 16000 + + +def _stopped_history_text(message, session, conversation_id: int) -> str: + """Project saved readable output only; never replay code or infer tool success.""" + body = (message.message_content or "").replace("", "").strip() + if body in {"已停止", "Stopped"}: + body = "" + if not body: + units = session.execute(select( + ConversationMessageUnit.unit_content, + ).where( + ConversationMessageUnit.conversation_id == conversation_id, + ConversationMessageUnit.message_id == message.message_id, + ConversationMessageUnit.unit_type == "execution_logs", + ConversationMessageUnit.unit_status == "completed", + ConversationMessageUnit.delete_flag == "N", + ).order_by(desc(ConversationMessageUnit.unit_index)).limit(16)).all() + body = "\n".join(unit.unit_content or "" for unit in reversed(units)) + files = message.minio_files + if isinstance(files, str): + try: + files = json.loads(files) + except (ValueError, TypeError): + files = None + if isinstance(files, list): + references = [ + str(item.get("url") or item.get("s3_url") or "") + for item in files if isinstance(item, dict) + ] + body += "\n" + "\n".join(ref for ref in references if ref) + body = body.strip()[:_PARTIAL_CONTEXT_MAX_CHARS] + return _STOPPED_CONTEXT_NOTE + ("\n" + body if body else "") + + +def _history_turn(user, assistant_text: str, assistant_id=None) -> Dict[str, Any]: + return { + "user_message": user.message_content or "", + "assistant_final_answer": assistant_text, + "attachments": user.minio_files, + "user_message_id": user.message_id, + "assistant_message_id": assistant_id if assistant_id is not None else -user.message_id, + } + + def get_historical_context( conversation_id: int, current_user_message_id: int, user_id: str, tenant_id: str, ) -> Optional[Dict[str, Any]]: - """Load the authorized latest checkpoint and completed turns before a run.""" + """Load authorized completed and stopped turns after the latest valid checkpoint.""" if not user_id or not tenant_id: return None user_tenant = _get_user_tenant(user_id) @@ -2204,11 +2251,12 @@ def get_historical_context( ConversationMessage.message_role, ConversationMessage.message_content, ConversationMessage.minio_files, + ConversationMessage.status, ).where( ConversationMessage.conversation_id == conversation_id, ConversationMessage.message_index > boundary_index, ConversationMessage.message_index < current.message_index, - ConversationMessage.status == 'completed', + ConversationMessage.status.in_(['completed', 'stopped']), ConversationMessage.delete_flag == 'N', ConversationMessage.message_role.in_(['user', 'assistant']), ).order_by(asc(ConversationMessage.message_index))).all() @@ -2217,16 +2265,18 @@ def get_historical_context( pending_user = None for message in messages: if message.message_role == 'user': + if pending_user is not None: + turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) pending_user = message elif pending_user is not None: - turns.append({ - "user_message": pending_user.message_content or "", - "assistant_final_answer": message.message_content or "", - "attachments": pending_user.minio_files, - "user_message_id": pending_user.message_id, - "assistant_message_id": message.message_id, - }) + answer = ( + _stopped_history_text(message, session, conversation_id) + if message.status == 'stopped' else message.message_content or "" + ) + turns.append(_history_turn(pending_user, answer, message.message_id)) pending_user = None + if pending_user is not None: + turns.append(_history_turn(pending_user, _UNANSWERED_CONTEXT_NOTE)) summary_result = None if summary_record and summary_payload: diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 1ed5a13540..3c882d2347 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -6,7 +6,6 @@ CheckConstraint, Column, Computed, - DefaultClause, Float, ForeignKey, ForeignKeyConstraint, @@ -57,200 +56,6 @@ class TableBase(DeclarativeBase): pass -class HumanRun(TableBase): - """Durable run with a technical key and a stable public UUID.""" - - __tablename__ = "human_run_t" - __table_args__ = ( - Index("human_run_public_id_idx", "run_id"), - Index( - "human_run_conversation_idx", - "tenant_id", - "user_id", - "conversation_id", - "create_time", - postgresql_where=text("delete_flag = 'N'"), - ), - Index( - "human_run_claim_idx", "status", "lock_until", "create_time", postgresql_where=text("delete_flag = 'N'") - ), - {"schema": SCHEMA}, - ) - - run_record_id = Column(Integer, primary_key=True, autoincrement=True, comment="Technical run record identifier") - run_id = Column( - String(36), - nullable=False, - comment="Public UUID retained by HTTP, checkpoints and event payloads; service enforces uniqueness", - ) - tenant_id = Column(String(100), nullable=False, comment="Tenant owning this run and its dependent records") - user_id = Column(String(100), nullable=False, comment="User owning this run within the tenant") - conversation_id = Column( - Integer, - nullable=False, - comment="Logical conversation_record_t.conversation_id; service validates the active owner", - ) - status = Column( - String(30), nullable=False, comment="Run lifecycle state validated by the human interaction service" - ) - request_payload = Column( - Text, - nullable=False, - comment="Fernet encrypted JSON run input and context snapshot; private service-owned payload", - ) - checkpoint = Column(Text, comment="Fernet encrypted SDK checkpoint; null until the first durable boundary") - catalog_digest = Column(String(64), comment="SHA-256 identity of the agent, model and tool catalog") - executor_digest = Column(String(64), comment="SHA-256 identity of the registered executor implementation") - plan = Column(Text, comment="Fernet encrypted SDK plan snapshot; null when no plan exists") - plan_version = Column( - Integer, nullable=False, default=0, server_default=text("0"), comment="Monotonic revision of the saved plan" - ) - fence = Column( - Integer, - nullable=False, - default=0, - server_default=text("0"), - comment="Lease generation rejecting stale worker writes", - ) - lock_owner = Column(String(200), comment="Scheduler worker identity, bounded to 200 characters") - lock_until = Column(TIMESTAMP(timezone=True), comment="UTC lease deadline; null when no worker owns the run") - pause_requested = Column( - Integer, - nullable=False, - default=0, - server_default=text("0"), - comment="Pause request marker: 0 or 1, validated by the service", - ) - event_seq = Column( - BigInteger, - nullable=False, - default=0, - server_default=text("0"), - comment="64-bit SSE event counter; not a record identifier, allocated under the run lock", - ) - - -class HumanRequest(TableBase): - """Human decision associated with one integer run record identifier.""" - - __tablename__ = "human_request_t" - __table_args__ = ( - Index("human_request_run_idx", "run_record_id", "status", postgresql_where=text("delete_flag = 'N'")), - {"schema": SCHEMA}, - ) - - request_record_id = Column( - Integer, primary_key=True, autoincrement=True, comment="Technical human request record identifier" - ) - request_id = Column( - String(36), - nullable=False, - comment="Public request UUID; uniqueness is scoped to the owning run by the service", - ) - run_record_id = Column( - Integer, nullable=False, comment="Logical human_run_t.run_record_id; validated under the parent run lock" - ) - kind = Column( - String(30), nullable=False, comment="CLARIFICATION, ACTION_APPROVAL or USER_STEERING; service validated" - ) - status = Column(String(30), nullable=False, comment="PENDING, DECIDED, CANCELLED or EXPIRED; service validated") - version = Column( - Integer, - nullable=False, - default=1, - server_default=text("1"), - comment="Positive request revision used for decision compare-and-set", - ) - slot = Column(String(100), nullable=False, comment="SDK action slot or composer guidance identity within the run") - digest = Column(String(64), nullable=False, comment="SHA-256 action identity required when submitting a decision") - payload = Column( - Text, - nullable=False, - comment="Fernet encrypted clarification, approval or steering payload, validated by the service", - ) - decision = Column( - Text, comment="Fernet encrypted validated DecisionCommand or composer decision; null before a decision" - ) - idempotency_key = Column( - String(100), - comment="Decision retry key scoped to this request, or composer message identity scoped to the run", - ) - decision_digest = Column(String(64), comment="SHA-256 of the accepted decision, detecting conflicting retries") - expires_at = Column( - TIMESTAMP(timezone=True), - nullable=False, - comment="UTC deadline after which a pending decision cannot authorize execution", - ) - - -class HumanExecution(TableBase): - """Tool execution receipt; the service serializes each run and call slot.""" - - __tablename__ = "human_execution_t" - __table_args__ = ( - Index("human_execution_slot_idx", "run_record_id", "slot", postgresql_where=text("delete_flag = 'N'")), - {"schema": SCHEMA}, - ) - - execution_id = Column( - Integer, primary_key=True, autoincrement=True, comment="Technical execution receipt identifier" - ) - run_record_id = Column( - Integer, nullable=False, comment="Logical human_run_t.run_record_id; validated under the parent run lock" - ) - slot = Column( - String(100), - nullable=False, - comment="Stable SDK call slot; one active receipt per run and slot is enforced by the service", - ) - tool = Column(String(200), nullable=False, comment="Registered tool name, bounded to 200 characters") - digest = Column(String(64), nullable=False, comment="HMAC-SHA-256 of the frozen action and execution context") - arguments = Column( - Text, nullable=False, comment="Fernet encrypted frozen tool arguments; variable SDK-owned JSON structure" - ) - status = Column( - String(30), nullable=False, comment="PREPARED, STARTED, SUCCEEDED, REJECTED or UNKNOWN; service validated" - ) - result = Column(Text, comment="Fernet encrypted result or rejection; null before a conclusive receipt") - - -class HumanEvent(TableBase): - """Ordered replay event owned by a run.""" - - __tablename__ = "human_event_t" - __table_args__ = ( - Index("human_event_replay_idx", "run_record_id", "seq", postgresql_where=text("delete_flag = 'N'")), - {"schema": SCHEMA}, - ) - - event_id = Column(Integer, primary_key=True, autoincrement=True, comment="Technical replay event identifier") - run_record_id = Column( - Integer, nullable=False, comment="Logical human_run_t.run_record_id; validated under the parent run lock" - ) - seq = Column( - BigInteger, - nullable=False, - comment="64-bit SSE cursor allocated from the owning run event_seq under its row lock", - ) - payload = Column( - JSONB, - nullable=False, - comment="Service-owned event envelope: either chunk_cipher string or type string and content object; no plaintext stream chunks", - ) - - -# Customize only inherited HITL audit metadata; retain TableBase as the single declaration. -for _human_model in (HumanRun, HumanRequest, HumanExecution, HumanEvent): - for _audit_name in ("created_by", "create_time", "updated_by", "update_time", "delete_flag"): - _audit_column = _human_model.__table__.c[_audit_name] - _audit_column.comment = _audit_column.doc - _audit_column.nullable = False - if _audit_name in {"create_time", "update_time"}: - _audit_column.server_default = DefaultClause(text("timezone('UTC', now())")) - elif _audit_name == "delete_flag": - _audit_column.server_default = DefaultClause(text("'N'")) - - class ConversationRecord(TableBase): """ Overall information table for Q&A conversations diff --git a/backend/database/human_interaction_db.py b/backend/database/human_interaction_db.py deleted file mode 100644 index 19575bc4ea..0000000000 --- a/backend/database/human_interaction_db.py +++ /dev/null @@ -1,327 +0,0 @@ -"""PostgreSQL unit of work; scoped locks serialize creation and run mutations.""" - -import hashlib -import json -from contextlib import contextmanager -from datetime import datetime, timedelta, timezone - -from sqlalchemy import and_, or_, select, text, update - -from .client import get_db_session -from .db_models import ( - ConversationRecord, - HumanEvent, - HumanExecution, - HumanRequest, - HumanRun, -) - -ACTIVE_STATUSES = ("INITIALIZING", "READY", "RUNNING", "WAITING_HUMAN", "RECOVERY_REQUIRED") -HUMAN_MODELS = (HumanRun, HumanRequest, HumanExecution, HumanEvent) - - -def utcnow(): - return datetime.now(timezone.utc) - - -def audit_now(): - """Store shared TIMESTAMP audit columns as UTC without a timezone offset.""" - return utcnow().replace(tzinfo=None) - - -def _lock(session, scope): - key = int.from_bytes(hashlib.sha256(json.dumps(scope).encode()).digest()[:8], "big", signed=True) - session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": key}) - - -class RunTransaction: - def __init__(self, session, run, validator, actor=None): - self.session = session - self.run = run - self.validator = validator - self.actor = actor or "system:hitl" - self.event_floor = run.event_seq or 0 - - def requests(self): - return list( - self.session.scalars( - select(HumanRequest).where( - HumanRequest.run_record_id == self.run.run_record_id, - HumanRequest.delete_flag == "N", - ) - ) - ) - - def executions(self): - return list( - self.session.scalars( - select(HumanExecution).where( - HumanExecution.run_record_id == self.run.run_record_id, - HumanExecution.delete_flag == "N", - ) - ) - ) - - def execution(self, slot): - return self.session.scalar( - select(HumanExecution).where( - HumanExecution.run_record_id == self.run.run_record_id, - HumanExecution.slot == slot, - HumanExecution.delete_flag == "N", - ) - ) - - def conversation_exists(self): - return ( - self.session.scalar( - select(ConversationRecord.conversation_id) - .where( - ConversationRecord.conversation_id == self.run.conversation_id, - ConversationRecord.created_by == self.run.user_id, - ConversationRecord.delete_flag == "N", - ) - .with_for_update() - ) - is not None - ) - - def active_run_exists(self): - return ( - self.session.scalar( - select(HumanRun.run_record_id).where( - HumanRun.tenant_id == self.run.tenant_id, - HumanRun.user_id == self.run.user_id, - HumanRun.conversation_id == self.run.conversation_id, - HumanRun.status.in_(ACTIVE_STATUSES), - HumanRun.delete_flag == "N", - ) - ) - is not None - ) - - def run_id_exists(self): - # Public run identities must not alias even a retained, deleted run. - return ( - self.session.scalar( - select(HumanRun.run_record_id).where( - HumanRun.run_id == self.run.run_id, - ) - ) - is not None - ) - - def add(self, value): - self.session.add(value) - self.flush() - - def _prepare(self, value, *, creating): - now = audit_now() - if creating: - value.created_by = value.updated_by = self.actor - value.create_time = value.update_time = now - value.delete_flag = "N" - else: - value.updated_by = self.actor - value.update_time = now - self.validator(value, self, creating=creating) - - def flush(self): - if self.run.delete_flag == "Y": - self._prepare(self.run, creating=False) - for model in (HumanRequest, HumanExecution, HumanEvent): - self.session.execute( - update(model) - .where( - model.run_record_id == self.run.run_record_id, - model.delete_flag == "N", - ) - .values(delete_flag="Y", updated_by=self.actor, update_time=audit_now()) - ) - for value in list(self.session.new): - if isinstance(value, HUMAN_MODELS): - self._prepare(value, creating=True) - for value in list(self.session.dirty): - if isinstance(value, HUMAN_MODELS) and self.session.is_modified(value): - self._prepare(value, creating=False) - self.session.flush() - - def emit(self, payload): - self.run.event_seq += 1 - event = HumanEvent(run_record_id=self.run.run_record_id, seq=self.run.event_seq, payload=payload) - self.session.add(event) - - -class HumanInteractionRepository: - def __init__(self, session_factory=get_db_session): - self.session_factory = session_factory - self.validator = None - - @contextmanager - def transaction(self, run_id, tenant_id=None, user_id=None): - with self.session_factory() as session, session.no_autoflush: - conditions = [HumanRun.run_id == run_id, HumanRun.delete_flag == "N"] - if tenant_id is not None: - conditions.extend([HumanRun.tenant_id == tenant_id, HumanRun.user_id == user_id]) - run = session.scalar(select(HumanRun).where(*conditions).with_for_update()) - tx = RunTransaction(session, run, self.validator, user_id) if run else None - yield tx - if tx: - tx.flush() - - @contextmanager - def read_only(self, run_id, tenant_id=None, user_id=None): - """Snapshot reads must not contend with write paths. No lock, no writes.""" - with self.session_factory() as session: - conditions = [HumanRun.run_id == run_id, HumanRun.delete_flag == "N"] - if tenant_id is not None: - conditions.extend([HumanRun.tenant_id == tenant_id, HumanRun.user_id == user_id]) - run = session.scalar(select(HumanRun).where(*conditions)) - yield RunTransaction(session, run, self.validator, user_id) if run else None - - @contextmanager - def creation(self, run): - with self.session_factory() as session, session.no_autoflush: - _lock(session, ["human-run-id", run.run_id]) - _lock(session, ["human-conversation", run.tenant_id, run.user_id, run.conversation_id]) - tx = RunTransaction(session, run, self.validator, run.user_id) - tx._prepare(run, creating=True) - yield tx - tx.flush() - - def latest(self, tenant_id, user_id, conversation_id, *, active_only=False): - with self.session_factory() as session: - conditions = [ - HumanRun.tenant_id == tenant_id, - HumanRun.user_id == user_id, - HumanRun.conversation_id == conversation_id, - HumanRun.delete_flag == "N", - ] - if active_only: - conditions.append(HumanRun.status.in_(ACTIVE_STATUSES)) - return session.scalar( - select(HumanRun.run_id) - .where(*conditions) - .order_by( - HumanRun.create_time.desc(), - HumanRun.run_record_id.desc(), - ) - .limit(1) - ) - - def events(self, run_id, after=0): - with self.session_factory() as session: - rows = session.scalars( - select(HumanEvent) - .join( - HumanRun, - HumanRun.run_record_id == HumanEvent.run_record_id, - ) - .where( - HumanRun.run_id == run_id, - HumanRun.delete_flag == "N", - HumanEvent.delete_flag == "N", - HumanEvent.seq > after, - ) - .order_by(HumanEvent.seq) - .limit(200) - ) - return [{"seq": row.seq, "payload": row.payload} for row in rows] - - def claim(self, owner_id, limit, seconds): - with self.session_factory() as session, session.no_autoflush: - now = utcnow() - abandoned = list( - session.scalars( - select(HumanRun) - .where( - HumanRun.delete_flag == "N", - HumanRun.status == "INITIALIZING", - HumanRun.create_time < audit_now() - timedelta(seconds=seconds), - ) - .with_for_update(skip_locked=True) - .limit(limit) - ) - ) - for run in abandoned: - run.status = "FAILED" - tx = RunTransaction(session, run, self.validator) - tx.emit( - { - "type": "human_run", - "content": { - "run_id": run.run_id, - "status": run.status, - }, - } - ) - tx.flush() - rows = list( - session.scalars( - select(HumanRun) - .where( - HumanRun.delete_flag == "N", - or_(HumanRun.status == "READY", and_(HumanRun.status == "RUNNING", HumanRun.lock_until < now)), - or_(HumanRun.lock_until.is_(None), HumanRun.lock_until < now), - ) - .order_by(HumanRun.create_time) - .with_for_update(skip_locked=True) - .limit(limit) - ) - ) - result = [] - for run in rows: - tx = RunTransaction(session, run, self.validator) - if any(item.status in {"STARTED", "UNKNOWN"} for item in tx.executions()): - run.status = "RECOVERY_REQUIRED" - tx.emit({"type": "human_run", "content": {"run_id": run.run_id, "status": run.status}}) - tx.flush() - continue - run.status = "RUNNING" - run.fence += 1 - run.lock_owner = owner_id - run.lock_until = now + timedelta(seconds=seconds) - tx.flush() - result.append( - { - "run_id": run.run_id, - "tenant_id": run.tenant_id, - "user_id": run.user_id, - "conversation_id": run.conversation_id, - "fence": run.fence, - } - ) - return result - - def renew(self, run_id, owner_id, seconds): - with self.transaction(run_id) as tx: - if ( - tx is None - or tx.run.lock_owner != owner_id - or tx.run.lock_until is None - or tx.run.lock_until <= utcnow() - ): - return False - tx.run.lock_until = utcnow() + timedelta(seconds=seconds) - return True - - def release(self, run_id, owner_id): - with self.transaction(run_id) as tx: - if tx is None or tx.run.lock_owner != owner_id: - return False - tx.run.lock_owner = None - tx.run.lock_until = None - if tx.run.status == "RUNNING": - tx.run.status = "RECOVERY_REQUIRED" - tx.emit({"type": "human_run", "content": {"run_id": run_id, "status": tx.run.status}}) - return True - - def waiting_ids(self): - with self.session_factory() as session: - return list( - session.scalars( - select(HumanRun.run_id).where( - HumanRun.status == "WAITING_HUMAN", - HumanRun.delete_flag == "N", - ) - ) - ) diff --git a/backend/database/human_interaction_models.py b/backend/database/human_interaction_models.py deleted file mode 100644 index 1086c9077d..0000000000 --- a/backend/database/human_interaction_models.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility imports; canonical HITL models inherit TableBase in db_models.""" - -from .db_models import HumanEvent, HumanExecution, HumanRequest, HumanRun - -__all__ = ["HumanEvent", "HumanExecution", "HumanRequest", "HumanRun"] diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 9cbe6b7fb7..3ca6ec8471 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -483,19 +483,13 @@ async def _iter_run_chunks(): execution=execution, deferred_run=deferred_run, ) - interaction = getattr(agent_run_info, "human_interaction", None) - port = getattr(interaction, "port", None) - if callable(getattr(port, "visible_guidance", None)): - from services.human_interaction.stream import stream_with_guidance - - source = stream_with_guidance(source, port) async for agent_chunk in source: yield agent_chunk try: async for chunk in _iter_run_chunks(): chunk_type: Optional[str] = None - chunk_content: str = "" + chunk_content: Any = "" try: data = json.loads(chunk) chunk_type = data.get("type") @@ -808,13 +802,7 @@ async def _iter_run_chunks(): else "failed" ) outcome = getattr(agent_run_info, "attempt_outcome", None) - if ( - getattr(agent_run_info, "human_interaction", None) is not None - and isinstance(outcome, str) - ): - terminal_status = outcome if stream_completed_normally else "recovery_required" - agent_run_info.attempt_outcome = terminal_status - elif outcome in {"failed", "stopped"}: + if outcome in {"failed", "stopped"}: # A typed terminal model error is delivered as a normal observer # ``error`` chunk, so the async iterator can finish normally while # the worker outcome still authoritatively marks the run failed. @@ -931,8 +919,7 @@ async def _iter_run_chunks(): except Exception: persistence_failed = True terminal_status = "failed" - if getattr(agent_run_info, "human_interaction", None) is not None: - agent_run_info.attempt_outcome = "recovery_required" + agent_run_info.attempt_outcome = "failed" logger.exception( "Failed to persist assistant stream batch conversation=%s message=%s", agent_request.conversation_id, @@ -1508,32 +1495,6 @@ async def run_agent_stream( user_id=user_id, tenant_id=tenant_id, ) - if isinstance(agent_request.hitl_run_id, str) and agent_request.hitl_run_id: - from services.human_interaction.application import stream_run - - return await stream_run( - agent_request.hitl_run_id, - resolved_tenant_id, - resolved_user_id, - after=agent_request.hitl_after_event, - ) - - from consts.const import HITL_ENABLED - - if HITL_ENABLED and not agent_request.is_debug and agent_request.conversation_id: - from services.human_interaction.application import get_service, stream_run - from services.human_interaction.models import InteractionError - - active_hitl = await run_blocking( - "hitl-get_service-repository-latest", get_service().repository.latest, resolved_tenant_id, - resolved_user_id, agent_request.conversation_id, active_only=True, lane="control-io", - owner=__name__, - ) - if active_hitl: - if resume: - return await stream_run(active_hitl, resolved_tenant_id, resolved_user_id) - raise InteractionError("This conversation has a paused or active human interaction run") - if agent_request.is_debug and not resume: # Debug executions deliberately do not create conversations, so they # need a transient identifier for lifecycle operations such as stop. @@ -1765,15 +1726,6 @@ async def run_agent_stream( user_id=resolved_user_id, ) - if agent_request.enable_hitl is True and not resume: - from services.human_interaction.application import start_run - - human_response = await start_run( - agent_request, resolved_tenant_id, resolved_user_id, language, skip_user_save=skip_user_save, - ) - if human_response is not None: - return human_response - # Resume mode: check for existing streaming message if resume: resume_info = _detect_resume_position( @@ -2288,13 +2240,4 @@ def stop_agent_tasks(conversation_id: int | str, user_id: str): def is_agent_running(conversation_id: int, user_id: str) -> bool: - if agent_run_manager.get_agent_run_info(conversation_id, user_id) is not None: - return True - - from consts.const import HITL_ENABLED - - if not HITL_ENABLED: - return False - from services.human_interaction.application import is_conversation_running - - return is_conversation_running(conversation_id, user_id) + return agent_run_manager.get_agent_run_info(conversation_id, user_id) is not None diff --git a/backend/prompts/utils/prompt_generate_en.yaml b/backend/prompts/utils/prompt_generate_en.yaml index 1396142c63..8fdee7833a 100644 --- a/backend/prompts/utils/prompt_generate_en.yaml +++ b/backend/prompts/utils/prompt_generate_en.yaml @@ -62,7 +62,7 @@ FEW_SHOTS_SYSTEM_PROMPT: |- - To distinguish between code execution and displaying user code, use 'code' for executing code and 'code' for displaying code - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code. - After thinking, when you can answer the user, call `final_answer(...)` inside the single `...` action block. Never return a bare-text final answer. + Prefer one complete `...` block per action. If multiple blocks are necessary, emit them consecutively with whitespace only between them; they execute together as one action, and no prose may follow the first or final block. After thinking, when you can answer the user, call `final_answer(...)` as the last top-level statement in the final block. Never return a bare-text final answer or execute another action afterward. ### Python Code Specifications 1. If it is considered to be code that needs to be executed, use 'code'. If the code does not need to be executed for display only, use 'code', where language_type can be python, java, javascript, etc.; diff --git a/backend/prompts/utils/prompt_generate_zh.yaml b/backend/prompts/utils/prompt_generate_zh.yaml index a97eba8e07..6fdfe6435c 100644 --- a/backend/prompts/utils/prompt_generate_zh.yaml +++ b/backend/prompts/utils/prompt_generate_zh.yaml @@ -61,7 +61,7 @@ FEW_SHOTS_SYSTEM_PROMPT: |- - 考虑到代码执行与展示用户代码的区别,使用'代码'表达运行代码,使用'代码'表达展示代码 - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。 - 在思考结束后,当Agent认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。 + 每个动作优先使用一个完整的 `...` 代码块;如确需多个代码块,必须相邻且块间只能有空白,它们会合并为同一个动作执行,第一个代码块开始后及最后一个代码块之后不得输出正文。在思考结束后,当Agent认为可以回答用户问题,必须在最后一个代码块的最后一条顶层语句中调用 `final_answer(...)`;禁止输出裸文本最终回答或在其后继续执行动作。 ### python代码规范 1. 如果认为是需要执行的代码,使用'代码'格式;如果是不需要执行仅用于展示的代码,使用'代码'格式,其中语言类型例如python、java、javascript等; diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5f66307736..6deec3deb0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "email-validator>=2.0.0", "aiohttp>=3.8.0", "authlib>=1.3.0", - "cryptography>=42.0.0", "psycopg2-binary>=2.9.9", "PyJWT>=2.8.0", "sqlalchemy~=2.0.37", diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 333569a29e..86ac3c650d 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -887,6 +887,8 @@ def get_conversation_history_service(conversation_id: int, user_id: str) -> List if 'minio_files' in msg and msg['minio_files']: message_item['minio_files'] = msg['minio_files'] + message_item["status"] = msg.get("status", "completed") + # Keep the logical message position so clients can distinguish a # regenerated branch from a separate turn with identical text. if msg.get('message_index') is not None: diff --git a/backend/services/human_interaction/__init__.py b/backend/services/human_interaction/__init__.py deleted file mode 100644 index 7b95c1fc96..0000000000 --- a/backend/services/human_interaction/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Human interaction lifecycle and application runtime adapters.""" diff --git a/backend/services/human_interaction/application.py b/backend/services/human_interaction/application.py deleted file mode 100644 index bf84b5e710..0000000000 --- a/backend/services/human_interaction/application.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Composition root: authorized Agent preparation, durable scheduling, and SSE delivery.""" - -import asyncio -import json -import time -from contextlib import suppress -from functools import lru_cache - -from fastapi.responses import StreamingResponse -from nexent.core.concurrency import run_blocking -from nexent.core.human_interaction.contracts import RecoveryRequired, RunTerminated -from nexent.core.human_interaction.live_runtime import LiveHumanInteractionRuntime -from nexent.core.human_interaction.runtime import HumanInteractionRuntime - -from consts.const import ( - HITL_ACCEPT_NEW_RUNS, - HITL_ENABLED, - HITL_ENCRYPTION_KEY, - HITL_MAX_CONCURRENCY, - HITL_TOOL_APPROVAL_ENABLED, - HITL_WAIT_SECONDS, -) -from database.human_interaction_db import HumanInteractionRepository -from nexent.scheduler import ClaimedJob, LeaseScheduler, SchedulerConfig - -from .crypto import PayloadCipher -from .models import InteractionError -from .runtime_port import RuntimeInteractionPort -from .service import TERMINAL_STATUSES, HumanInteractionService - - -@lru_cache(maxsize=1) -def get_service(): - return HumanInteractionService(HumanInteractionRepository(), PayloadCipher(HITL_ENCRYPTION_KEY), HITL_WAIT_SECONDS) - - -def require_enabled(): - if not HITL_ENABLED: - raise InteractionError("Human interaction is not enabled on this deployment", 503) - return get_service() - - -def _stable_value(value): - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, dict): - transient = {"workspace_path", "run_id", "workspace_run_id", "observer", "stop_event", "redis_client", - "context_manager_config", "pre_run_tool_events", "context_items", "minio_client"} - return {key: _stable_value(item) for key, item in value.items() if key not in transient and not callable(item)} - if isinstance(value, (list, tuple)): - return [_stable_value(item) for item in value] - return type(value).__module__ + "." + type(value).__qualname__ - - -def _allowed_tool_names(tools, *, approval_enabled=HITL_TOOL_APPROVAL_ENABLED): - if not approval_enabled: - allowed = {tool.name for tool in tools if tool.name} - allowed.add("final_answer") - return frozenset(allowed) - trusted_plan_classes = {"CreatePlanTool", "UpdatePlanStepTool"} - allowed = { - tool.name - for tool in tools - if tool.name and tool.source == "local" and tool.class_name in trusted_plan_classes - } - allowed.add("final_answer") - return frozenset(allowed) - - -async def authorize_run(payload, tenant_id, user_id): - from database.user_tenant_db import get_user_tenant_by_user_id - from management.services.agent.management import list_all_agent_info_impl - from services.conversation_management_service import get_conversation_service - - membership = await run_blocking( - "hitl-get_user_tenant_by_user_id", get_user_tenant_by_user_id, user_id, lane="control-io", - owner=__name__, - ) - if not membership or str(membership.get("tenant_id")) != tenant_id: - raise InteractionError("The run owner no longer belongs to this tenant", 403) - conversation = await run_blocking( - "hitl-get_conversation_service", get_conversation_service, payload["conversation_id"], user_id, - tenant_id, lane="control-io", owner=__name__, - ) - if conversation is None: - raise InteractionError("Conversation is no longer accessible", 403) - agents = await list_all_agent_info_impl(tenant_id, user_id) - if not any(item.get("agent_id") == payload["agent_id"] for item in agents): - raise InteractionError("Agent is no longer accessible", 403) - - -async def execute_attempt(job, lease): - from consts.model import AgentRequest - from management.services.agent.run import ( - _stream_agent_chunks, - _unregister_agent_run_after_execution, - prepare_agent_run, - ) - - service = get_service() - identity = job.payload - loop = asyncio.get_running_loop() - run_info = None - port = None - try: - # Authorization is re-evaluated on every dispatch, including result replay. - def authorize(): - try: - asyncio.run_coroutine_threadsafe( - authorize_run(port.request_payload["request"], identity["tenant_id"], identity["user_id"]), loop, - ).result(timeout=30) - except Exception as exc: - raise RunTerminated("Run authorization could not be revalidated") from exc - - def report_waiting(waiting: bool) -> None: - # Worker threads park inside _wait_until_ready; relay the state to - # the scheduler loop so human waits do not consume execution slots. - # The reporter is a pure concurrency-budget optimization: a dead - # loop (service shutdown) or a stale SDK copy without mark_waiting - # must degrade to slot-consuming waits, never break execution. - mark_waiting = getattr(human_run_scheduler, "mark_waiting", None) - if mark_waiting is None: - return - # call_soon_threadsafe takes no kwargs: wrap the keyword-only flag. - try: - loop.call_soon_threadsafe(lambda: mark_waiting(lease.job_id, waiting=waiting)) - except RuntimeError: - pass - - port = RuntimeInteractionPort( - service, identity, lease.owner_id, authorize, live_resume=True, wait_reporter=report_waiting, - ) - saved = port.request_payload - if saved.get("runtime_mode") == "native-live-v1" and port.checkpoint: - raise RecoveryRequired("The original native execution is no longer available") - request = AgentRequest.model_validate(saved["request"]) - request.__dict__["_runtime_metadata_snapshot"] = saved["runtime_metadata"] - request.__dict__["_runtime_metadata_version"] = saved["runtime_metadata_version"] - request.__dict__["_runtime_knowledge_context"] = saved.get("runtime_knowledge_context") - await authorize_run(saved["request"], identity["tenant_id"], identity["user_id"]) - run_info, memory_context = await prepare_agent_run( - agent_request=request, user_id=identity["user_id"], tenant_id=identity["tenant_id"], - language=saved["language"], allow_memory_search=True, - ) - port.stop_event = getattr(run_info, "stop_event", None) - config = run_info.agent_config - native = saved.get("runtime_mode") == "native-live-v1" - runtime_type = LiveHumanInteractionRuntime if native else HumanInteractionRuntime - if not native: - if config.managed_agents or config.external_a2a_agents or request.minio_files: - # A legacy strict approval policy must never be silently bypassed. - raise RecoveryRequired("The frozen approval executor cannot safely execute this configuration") - # Legacy durable runs retain their frozen executor contract. New chat - # runs preserve the native sandbox, attachment workspace and tool registry. - run_info.sandbox_config = None - run_info.workspace_path = None - run_info.minio_files = None - from nexent.core.agents.context import ContextItemInput - from nexent.core.agents.context_input import ContextInput - prepared_items = [item.model_dump(mode="json") for item in run_info.context_input.items] - prepared_items.append(ContextItemInput( - id="system:human_interaction", type="system", source=("runtime",), priority=100, - content={"text": runtime_type.instructions}, - ).model_dump(mode="json")) - context_items = await run_blocking( - "hitl-port-context_snapshot", port.context_snapshot, prepared_items, lane="control-io", - owner=__name__, - ) - run_info.context_input = ContextInput( - items=tuple(ContextItemInput.model_validate(item) for item in context_items) - ) - catalog = _stable_value({ - "agent": config.model_dump(), - "models": [item.model_dump() for item in run_info.model_config_list], - "metadata": run_info.runtime_metadata, - }) - await run_blocking("hitl-port-bind_catalog", port.bind_catalog, catalog, lane="control-io", owner=__name__) - # Clarification and action approval share one suspension mechanism, but - # enabling clarification must not silently turn every tool into a high-risk action. - # Deployments opt into the conservative approval gate independently. - port.allowed_tools = _allowed_tool_names(config.tools) - run_info.human_interaction = runtime_type(port) - - _FLUSH_INTERVAL = 0.05 - _FLUSH_BATCH = 16 - - last_flush = time.monotonic() - - async def _flush_if_due() -> None: - """Flush buffered chunks on the interval/batch trigger to keep DB order. - - The async loop flushes on its own; the worker flushes again before - each HITL transaction. - """ - nonlocal last_flush - if ((time.monotonic() - last_flush >= _FLUSH_INTERVAL - or port.peek_chunks() >= _FLUSH_BATCH) - and port.peek_chunks()): - # drain_and_emit runs take_chunks + emit_chunks in one - # emit-lock critical section on the lane thread, so it cannot - # interleave with the worker's flush — seq order stays - # production order. - await run_blocking( - "hitl-port-drain_and_emit", port.drain_and_emit, - lane="control-io", owner=__name__, - ) - last_flush = time.monotonic() - - chunk_iter = _stream_agent_chunks( - agent_request=request, user_id=identity["user_id"], tenant_id=identity["tenant_id"], - agent_run_info=run_info, memory_ctx=memory_context, - ).__aiter__() - - anext_task: asyncio.Task | None = None - try: - anext_task = asyncio.create_task(chunk_iter.__anext__()) - while True: - # On timeout the task stays pending; the worker may resume it later. - done, pending = await asyncio.wait( - {anext_task}, - timeout=_FLUSH_INTERVAL, - return_when=asyncio.FIRST_COMPLETED, - ) - if pending: - # No new chunk yet; flush what we have to keep DB order. - await _flush_if_due() - continue - - task = done.pop() - try: - chunk = task.result() - except StopAsyncIteration: - # Stream exhausted: drop the consumed task so the finally - # block does not re-raise its StopAsyncIteration and skip - # the leftover flush plus the terminal finish() write. - anext_task = None - break - port.add_chunk(chunk) - await _flush_if_due() - anext_task = asyncio.create_task(chunk_iter.__anext__()) - finally: - if anext_task is not None: - anext_task.cancel() - with suppress(asyncio.CancelledError): - await anext_task - try: - await chunk_iter.aclose() - except Exception: - pass - # Final flush so leftover chunks precede finish() in DB order. - await run_blocking( - "hitl-port-drain_and_emit", port.drain_and_emit, - lane="control-io", owner=__name__, - ) - await run_blocking( - "hitl-port-finish", port.finish, run_info.attempt_outcome or "failed", lane="control-io", - owner=__name__, - ) - except asyncio.CancelledError: - if run_info is not None: - if run_info.cancellation_scope is not None: - run_info.cancellation_scope.cancel() - else: - run_info.stop_event.set() - raise - except RunTerminated: - if port is not None: - try: - await run_blocking("hitl-port-finish", port.finish, "stopped", lane="control-io", owner=__name__) - except RunTerminated: - pass - except RecoveryRequired: - if port is not None: - await run_blocking( - "hitl-port-finish", port.finish, "recovery_required", lane="control-io", owner=__name__, - ) - except Exception: - if port is not None: - await run_blocking("hitl-port-finish", port.finish, "failed", lane="control-io", owner=__name__) - raise - finally: - if run_info is not None: - _unregister_agent_run_after_execution( - identity["conversation_id"], identity["user_id"], - run_info.attempt_outcome or "failed", agent_run_info=run_info, - ) - - -def is_conversation_running(conversation_id, user_id): - """Return whether an owner has an active durable run in this conversation.""" - from database.user_tenant_db import get_user_tenant_by_user_id - - if not HITL_ENABLED: - return False - membership = get_user_tenant_by_user_id(user_id) or {} - tenant_id = membership.get("tenant_id") - return bool(tenant_id) and get_service().repository.latest( - str(tenant_id), user_id, conversation_id, active_only=True, - ) is not None - - -class HumanRunLeaseStore: - async def recover(self): - await run_blocking( - "hitl-get_service-expire_waiting", get_service().expire_waiting, lane="control-io", - owner=__name__, - ) - - async def claim_due(self, owner_id, limit, lease_seconds): - await self.recover() - rows = await run_blocking( - "hitl-get_service-repository-claim", get_service().repository.claim, owner_id, limit, - lease_seconds, lane="control-io", owner=__name__, - ) - return [ClaimedJob(job_id=row["run_id"], payload=row) for row in rows] - - async def renew(self, job_id, owner_id, lease_seconds): - return await run_blocking( - "hitl-get_service-repository-renew", get_service().repository.renew, job_id, owner_id, - lease_seconds, lane="control-io", owner=__name__, - ) - - async def release(self, job_id, owner_id): - return await run_blocking( - "hitl-get_service-repository-release", get_service().repository.release, job_id, owner_id, - lane="control-io", owner=__name__, - ) - - -human_run_scheduler = LeaseScheduler(HumanRunLeaseStore(), execute_attempt, SchedulerConfig( - poll_interval_seconds=1, lease_seconds=120, max_concurrency=HITL_MAX_CONCURRENCY, -)) - - -async def stream_run(run_id, tenant_id, user_id, *, after=0): - service = require_enabled() - snapshot = await run_blocking( - "hitl-service-snapshot", service.light_snapshot, run_id, tenant_id, user_id, lane="control-io", - owner=__name__, - ) - if after > snapshot["event_seq"]: - raise InteractionError("Event cursor is ahead of the run", 422) - - async def events(): - cursor = after - yield "data: " + json.dumps({"type": "human_run", "content": snapshot}) + "\n\n" - while True: - # Re-check ownership and deadlines for reconnecting subscribers. - current = await run_blocking( - "hitl-service-snapshot", service.light_snapshot, run_id, tenant_id, user_id, lane="control-io", - owner=__name__, - ) - rows = await run_blocking( - "hitl-service-repository-events", service.repository.events, run_id, cursor, - lane="control-io", owner=__name__, - ) - for row in rows: - cursor = row["seq"] - payload = row["payload"] - if "chunk_cipher" in payload: - yield f"id: {cursor}\n" + service.cipher.open(payload["chunk_cipher"]) - else: - yield f"id: {cursor}\ndata: " + json.dumps(payload, ensure_ascii=False) + "\n\n" - if cursor >= current["event_seq"] and (current["status"] in TERMINAL_STATUSES or ( - current["status"] == "WAITING_HUMAN" and not current["attempt_active"] and not rows)): - yield "data: " + json.dumps({"type": "human_run", "content": current}) + "\n\n" - break - if not rows: - yield ": heartbeat\n\n" - await asyncio.sleep(0.5) - - return StreamingResponse(events(), media_type="text/event-stream", headers={ - "Cache-Control": "no-cache", "Connection": "keep-alive", "run_id": run_id, - "conversation_id": str(snapshot["conversation_id"]), - }) - - -async def start_run(request, tenant_id, user_id, language, *, skip_user_save=False): - from agents.agent_run_manager import agent_run_manager - from management.services.agent.run import save_messages - - # Optional chat enhancement must not reject debug or deployments draining HITL. - # Existing runs still use their owner-scoped resume/decision APIs. - if request.is_debug or not HITL_ENABLED or not HITL_ACCEPT_NEW_RUNS: - return None - service = require_enabled() - payload = { - "runtime_mode": "linear-json-v1" if HITL_TOOL_APPROVAL_ENABLED else "native-live-v1", - "request": request.model_dump(mode="json"), "language": language, - "runtime_metadata": getattr(request, "_runtime_metadata_snapshot", {}), - "runtime_metadata_version": getattr(request, "_runtime_metadata_version", None), - "runtime_knowledge_context": getattr(request, "_runtime_knowledge_context", None), - } - await authorize_run(payload["request"], tenant_id, user_id) - if service.repository.latest(tenant_id, user_id, request.conversation_id, active_only=True): - raise InteractionError("This conversation already has an active run") - reservation = agent_run_manager.reserve_agent_run(request.conversation_id, user_id) - run_id = None - try: - run_id = await run_blocking( - "hitl-service-create", service.create, tenant_id, user_id, request.conversation_id, payload, - ready=False, lane="control-io", owner=__name__, - ) - if not skip_user_save: - save_messages(request, "user", user_id, tenant_id) - await run_blocking( - "hitl-service-initialized", service.initialized, run_id, tenant_id, user_id, succeeded=True, - lane="control-io", owner=__name__, - ) - except Exception: - if run_id: - await run_blocking( - "hitl-service-initialized", service.initialized, run_id, tenant_id, user_id, succeeded=False, - lane="control-io", owner=__name__, - ) - raise - finally: - agent_run_manager.release_agent_run_reservation(request.conversation_id, user_id, reservation) - return await stream_run(run_id, tenant_id, user_id) diff --git a/backend/services/human_interaction/crypto.py b/backend/services/human_interaction/crypto.py deleted file mode 100644 index 255b3df1e1..0000000000 --- a/backend/services/human_interaction/crypto.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Encrypt request, checkpoint, arguments and decisions with an operator-managed key.""" - -import base64 -import hashlib -import hmac -import json - -from cryptography.fernet import Fernet - -from nexent.core.human_interaction.codec import json_copy - - -class PayloadCipher: - def __init__(self, key: str): - if not key: - raise ValueError("HITL_ENCRYPTION_KEY is required when human interaction is enabled") - self.fernet = Fernet(key.encode()) - self.signing_key = base64.urlsafe_b64decode(key) - - def seal(self, value): - return self.fernet.encrypt(json.dumps(json_copy(value), ensure_ascii=False).encode()).decode() - - def open(self, value): - return json.loads(self.fernet.decrypt(value.encode())) if value else None - - def digest(self, value): - data = json.dumps(json_copy(value), sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode() - return hmac.new(self.signing_key, data, hashlib.sha256).hexdigest() diff --git a/backend/services/human_interaction/models.py b/backend/services/human_interaction/models.py deleted file mode 100644 index b778fd6465..0000000000 --- a/backend/services/human_interaction/models.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Strict public commands and secret-safe interaction projections.""" - -import hashlib -import json -import re -from typing import Literal - -from nexent.core.human_interaction.clarification import ClarificationAnswer -from pydantic import BaseModel, ConfigDict, Field - - -class InteractionError(Exception): - def __init__(self, message: str, status_code: int = 409): - super().__init__(message) - self.status_code = status_code - - -class DecisionCommand(BaseModel): - model_config = ConfigDict(extra="forbid") - version: int = Field(ge=1) - digest: str = Field(min_length=64, max_length=64) - idempotency_key: str = Field(min_length=8, max_length=100) - decision: Literal["answer", "approve", "reject", "steer"] - text: str | None = Field(default=None, max_length=8000) - answers: list[ClarificationAnswer] | None = Field(default=None, min_length=1, max_length=5) - - -class SteeringCommand(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) - message_id: str = Field(min_length=8, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$") - text: str = Field(min_length=1, max_length=8000) - - -def digest(value) -> str: - return hashlib.sha256(json.dumps( - value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False, - ).encode()).hexdigest() - - -_SECRET = re.compile(r"password|passwd|secret|token|credential|authorization|api.?key|cookie", re.I) - - -def redact(value): - if isinstance(value, dict): - return {key: "[REDACTED_SECRET]" if _SECRET.search(key) else redact(item) for key, item in value.items()} - if isinstance(value, list): - return [redact(item) for item in value] - return value diff --git a/backend/services/human_interaction/persistence.py b/backend/services/human_interaction/persistence.py deleted file mode 100644 index 1dd687f696..0000000000 --- a/backend/services/human_interaction/persistence.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Service-owned persistence validation, executed inside the repository's locks.""" - -import json - -from sqlalchemy import BigInteger, Integer, String - -from database.db_models import HumanEvent, HumanExecution, HumanRequest, HumanRun - -from .models import InteractionError - -RUN_STATUSES = { - "INITIALIZING", - "READY", - "RUNNING", - "WAITING_HUMAN", - "COMPLETED", - "FAILED", - "STOPPED", - "EXPIRED", - "RECOVERY_REQUIRED", -} -REQUEST_STATUSES = {"PENDING", "DECIDED", "CANCELLED", "EXPIRED"} -REQUEST_KINDS = {"CLARIFICATION", "ACTION_APPROVAL", "USER_STEERING"} -EXECUTION_STATUSES = {"PREPARED", "STARTED", "SUCCEEDED", "REJECTED", "UNKNOWN"} - - -def validate_record(row, tx, *, creating): - """Validate the final write, including scheduler and receipt updates.""" - for column in row.__table__.columns: - value = getattr(row, column.key) - if column.primary_key and creating and value is None: - continue - if value is None: - if not column.nullable: - raise InteractionError(f"{column.key} is required", 422) - continue - if ( - isinstance(column.type, String) - and column.type.length - and (not isinstance(value, str) or len(value) > column.type.length) - ): - raise InteractionError(f"{column.key} exceeds its string boundary", 422) - if isinstance(column.type, Integer): - maximum = 2**63 - 1 if isinstance(column.type, BigInteger) else 2**31 - 1 - if type(value) is not int or not 0 <= value <= maximum: - raise InteractionError(f"{column.key} is outside its integer boundary", 422) - if row.delete_flag not in {"N", "Y"}: - raise InteractionError("Invalid soft-delete marker", 422) - if isinstance(row, HumanRun): - if row.status not in RUN_STATUSES or row.pause_requested not in {0, 1}: - raise InteractionError("Invalid run state", 422) - return - if row.run_record_id != tx.run.run_record_id or ( - tx.run.delete_flag != "N" and (creating or row.delete_flag != "Y") - ): - raise InteractionError("The record must reference the active locked run", 422) - if isinstance(row, HumanRequest): - if row.kind not in REQUEST_KINDS or row.status not in REQUEST_STATUSES or row.version < 1: - raise InteractionError("Invalid human request state", 422) - others = [item for item in tx.requests() if item is not row] - if creating and any(item.request_id == row.request_id for item in others): - raise InteractionError("A request with this public identity already exists") - if row.status == "PENDING" and row.delete_flag == "N" and any(item.status == "PENDING" for item in others): - raise InteractionError("This run already has a pending human request") - elif isinstance(row, HumanExecution): - if row.status not in EXECUTION_STATUSES: - raise InteractionError("Invalid execution state", 422) - if creating and tx.execution(row.slot) is not None: - raise InteractionError("This run already has an execution at this slot") - elif isinstance(row, HumanEvent): - payload = row.payload - chunk = ( - isinstance(payload, dict) and set(payload) == {"chunk_cipher"} and isinstance(payload["chunk_cipher"], str) - ) - state = ( - isinstance(payload, dict) - and set(payload) == {"type", "content"} - and isinstance(payload["type"], str) - and bool(payload["type"]) - and isinstance(payload["content"], dict) - ) - if not (chunk or state): - raise InteractionError("Invalid replay event envelope", 422) - try: - json.dumps(payload, allow_nan=False) - except (TypeError, ValueError) as exc: - raise InteractionError("Replay event must contain JSON values", 422) from exc - if row.seq < 1 or row.seq > tx.run.event_seq: - raise InteractionError("Invalid replay event sequence", 422) - if creating and ( - row.seq <= tx.event_floor - or any( - isinstance(item, HumanEvent) - and item is not row - and item.run_record_id == row.run_record_id - and item.seq == row.seq - for item in tx.session.new - ) - ): - raise InteractionError("This run already has an event at this sequence") diff --git a/backend/services/human_interaction/runtime_port.py b/backend/services/human_interaction/runtime_port.py deleted file mode 100644 index d0345fb547..0000000000 --- a/backend/services/human_interaction/runtime_port.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Fenced tool dispatch adapter. Approval and STARTED are consumed under one run lock.""" - -import threading -import time -from contextlib import contextmanager -from datetime import timezone - -from database.human_interaction_db import utcnow -from database.human_interaction_models import HumanExecution -from nexent.core.human_interaction.contracts import ( - AttemptSuspended, - RecoveryRequired, - RunTerminated, -) - -from .models import digest, redact - - -class RuntimeInteractionPort: - def __init__(self, service, identity, owner_id, authorize, allowed_tools=(), *, live_resume=False, stop_event=None, - wait_reporter=None): - """Bind the port and set up async-worker chunk coordination. - - ``_chunk_buffer`` stages processed observer chunks: the async consumer - appends via add_chunk and the worker flushes before each HITL event so - chunk rows precede human_interaction rows in DB event order. - ``_emit_lock`` serializes every take_chunks + emit_chunks critical - section (async drain_and_emit and the worker's own flush), so a held - lock also means "a drain is in flight and its chunks are uncommitted". - ``wait_reporter`` relays enter/exit of human-input waits to the - scheduler so parked runs stop consuming execution concurrency slots. - """ - self.service = service - self.repository = service.repository - self.cipher = service.cipher - self.run_id = identity["run_id"] - self.tenant_id = identity["tenant_id"] - self.user_id = identity["user_id"] - self.fence = identity["fence"] - self.owner_id = owner_id - self.authorize = authorize - self.allowed_tools = frozenset(allowed_tools) - self.live_resume = live_resume - self.stop_event = stop_event - self.wait_reporter = wait_reporter - self._chunk_buffer: list[str] = [] - self._chunk_buffer_lock = threading.Lock() - self._emit_lock = threading.Lock() - with self.transaction() as tx: - self.checkpoint = self.cipher.open(tx.run.checkpoint) - self.request_payload = self.cipher.open(tx.run.request_payload) - - def add_chunk(self, chunk: str) -> None: - """Append a processed chunk from the async consumer to the shared buffer.""" - with self._chunk_buffer_lock: - self._chunk_buffer.append(chunk) - - def take_chunks(self) -> list[str]: - """Atomically drain the shared buffer for persistence.""" - with self._chunk_buffer_lock: - chunks = self._chunk_buffer - self._chunk_buffer = [] - return chunks - - def peek_chunks(self) -> int: - """Return the number of buffered chunks without draining them.""" - with self._chunk_buffer_lock: - return len(self._chunk_buffer) - - def drain_and_emit(self) -> None: - """Atomically drain and persist buffered chunks. Lane-thread only. - - take_chunks and emit_chunks share one critical section under the emit - lock so a concurrent flush can never interleave DB seq assignment — - the seq order then always matches the chunk production order. - """ - with self._emit_lock: - chunks = self.take_chunks() - if not chunks: - return - try: - self.emit_chunks(chunks) - except Exception: - # Never lose drained chunks; put them back for retry. - for chunk in chunks: - self.add_chunk(chunk) - raise - - def flush_chunks_until_idle(self, *, max_wait_ms: int = 500, settle_ms: int = 20) -> None: - """Wait for the async consumer to drain the observer queue, then persist. - - Worker-thread only. take_chunks and emit_chunks run inside the same - emit-lock critical section as the async drain_and_emit, so concurrent - flushes cannot reorder seq assignment. ``max_wait_ms`` bounds how long - we wait for NEW chunks — it must NOT fire while another drain holds - the lock, because its chunks are produced before this HITL event but - still uncommitted; returning early would order HITL rows ahead of - them. The deadline may therefore only fire once the lock is free. - """ - hard_deadline = time.monotonic() + max_wait_ms / 1000.0 - idle_since: float | None = None - - while True: - now = time.monotonic() - if now >= hard_deadline and not self._emit_lock.locked(): - break - with self._emit_lock: - chunks = self.take_chunks() - if chunks: - try: - self.emit_chunks(chunks) - except Exception: - # Never lose drained chunks; put them back for retry. - for chunk in chunks: - self.add_chunk(chunk) - raise - idle_since = None - elif idle_since is None: - idle_since = now - elif now - idle_since >= settle_ms / 1000.0: - break - time.sleep(min(settle_ms / 1000.0, max(hard_deadline - now, 0.0))) - - @contextmanager - def transaction(self, *, receipt=False): - with self.repository.transaction(self.run_id, self.tenant_id, self.user_id) as tx: - if (tx is None or tx.run.fence != self.fence or tx.run.lock_owner != self.owner_id - or tx.run.lock_until is None or tx.run.lock_until <= utcnow()): - raise RunTerminated("Execution lease is no longer valid") - if not receipt and tx.run.status not in {"RUNNING", "READY"}: - raise RunTerminated("Run no longer permits execution") - yield tx - - def bind_catalog(self, catalog): - value = digest(catalog) - with self.transaction() as tx: - if tx.run.catalog_digest is not None and tx.run.catalog_digest != value: - raise RecoveryRequired("Agent definition, model, tool configuration or credentials changed") - tx.run.catalog_digest = value - - def context_snapshot(self, items): - with self.transaction() as tx: - saved = self.cipher.open(tx.run.request_payload) - if "context_items" not in saved: - saved["context_items"] = items - tx.run.request_payload = self.cipher.seal(saved) - return saved["context_items"] - - def bind_executor(self, identity): - value = digest(identity) - with self.transaction() as tx: - if tx.run.executor_digest is not None and tx.run.executor_digest != value: - raise RecoveryRequired("Executor or registered tool implementation changed") - tx.run.executor_digest = value - - def save_checkpoint(self, checkpoint): - sealed = self.cipher.seal(checkpoint) - with self.transaction() as tx: - tx.run.checkpoint = sealed - self.checkpoint = checkpoint - - def boundary(self, checkpoint): - self.authorize() - suspended = False - feedback = None - # Flush so buffered chunks precede the human_run status row in DB order. - self.flush_chunks_until_idle() - with self.transaction() as tx: - tx.run.checkpoint = self.cipher.seal(checkpoint) - if tx.run.pause_requested: - self.service._request_steering(tx) - suspended = True - else: - pending = sorted(( - request for request in tx.requests() - if request.kind == "USER_STEERING" and request.status == "DECIDED" - and request.request_id not in checkpoint.get("steering_ids", []) - ), key=lambda request: (request.create_time, request.request_id)) - if pending: - completed = [] - for execution in tx.executions(): - if execution.status == "SUCCEEDED": - completed.append({"tool": execution.tool, "result": self.cipher.open(execution.result)}) - elif execution.status == "PREPARED": - execution.status = "REJECTED" - execution.result = self.cipher.seal({"status": "not_executed", "reason": "user_steering"}) - texts = [self.cipher.open(request.decision)["text"] for request in pending] - # Consume all accepted input at this boundary before making another model/tool call. - content = texts[0] if len(texts) == 1 else ( - "Successive user guidance in submission order; later corrections take precedence:\n" - + "\n\n".join(f"{index}. {value}" for index, value in enumerate(texts, 1)) - ) - feedback = {"text": content, "request_id": pending[0].request_id, - "request_ids": [request.request_id for request in pending], - "completed_actions": str(redact(completed))} - if suspended: - if not self.live_resume: - raise AttemptSuspended() - self._wait_until_ready() - return self.boundary(checkpoint) - return feedback - - def close_steering(self, checkpoint): - """Serialize finalization against newly submitted guidance under the run lock.""" - with self.transaction() as tx: - if tx.run.pause_requested or any( - item.kind == "USER_STEERING" and item.status == "DECIDED" - and item.request_id not in checkpoint.get("steering_ids", []) for item in tx.requests() - ): - return False - payload = self.cipher.open(tx.run.request_payload) - payload["steering_closed"] = True - tx.run.request_payload = self.cipher.seal(payload) - return True - - def _wait_until_ready(self): - """Park this worker while retaining the current Python continuation.""" - if self.wait_reporter is not None: - self.wait_reporter(True) - try: - next_authorization_check = 0.0 - while True: - if self.stop_event is not None and self.stop_event.is_set(): - raise RunTerminated("The managed execution was cancelled") - now = time.monotonic() - if now >= next_authorization_check: - self.authorize() - next_authorization_check = now + 5.0 - with self.repository.transaction(self.run_id, self.tenant_id, self.user_id) as tx: - if (tx is None or tx.run.fence != self.fence or tx.run.lock_owner != self.owner_id - or tx.run.lock_until is None or tx.run.lock_until <= utcnow()): - raise RunTerminated("Execution lease is no longer valid") - self.service._expire(tx) - if tx.run.status == "READY": - # Flush so lingering chunks precede the human_run row. - self.flush_chunks_until_idle() - tx.run.status = "RUNNING" - tx.emit({"type": "human_run", "content": { - "run_id": self.run_id, "status": "RUNNING", - }}) - return - if tx.run.status != "WAITING_HUMAN": - raise RunTerminated("Run no longer permits live continuation") - if self.stop_event is not None: - self.stop_event.wait(0.2) - else: - time.sleep(0.2) - finally: - if self.wait_reporter is not None: - self.wait_reporter(False) - - def dispatch(self, slot, tool, arguments, *, interaction=None): - self.authorize() - suspended = False - steering_requested = False - outcome = None - # Flush before the transaction so chunk rows get lower seq numbers - # than any subsequent human_interaction row. - self.flush_chunks_until_idle() - with self.transaction() as tx: - if tx.run.pause_requested: - self.service._request_steering(tx) - suspended = True - steering_requested = True - else: - action_digest = self.cipher.digest([self.run_id, self.tenant_id, self.user_id, slot, tool, - arguments, tx.run.catalog_digest, "conservative-v1"]) - execution = tx.execution(slot) - if execution is not None: - if execution.digest != action_digest: - raise RecoveryRequired("Arguments or tool identity changed at a persisted call slot") - if execution.status in {"SUCCEEDED", "REJECTED"}: - return {"status": "replay", "result": self.cipher.open(execution.result)} - if execution.status in {"STARTED", "UNKNOWN"}: - raise RecoveryRequired("An uncertain external effect must be reconciled") - else: - execution = HumanExecution(run_record_id=tx.run.run_record_id, slot=slot, tool=tool, digest=action_digest, - arguments=self.cipher.seal(arguments), status="PREPARED") - tx.add(execution) - request = next((item for item in tx.requests() - if item.slot == slot and item.digest == action_digest), None) - if request is not None: - if request.status == "CANCELLED" and any( - item.kind == "USER_STEERING" and item.status == "DECIDED" - for item in tx.requests()): - return {"status": "steered"} - if request.expires_at <= utcnow() or request.status != "DECIDED": - raise RunTerminated("No valid unexpired human decision is available") - decision = self.cipher.open(request.decision) - if decision["decision"] in {"answer", "reject"}: - if decision["decision"] == "answer": - result = self.service.clarification_result(request, decision) - else: - result = { - "status": "not_executed", "reason": "human_rejected", "feedback": decision.get("text"), - } - execution.status = "SUCCEEDED" if decision["decision"] == "answer" else "REJECTED" - execution.result = self.cipher.seal(result) - outcome = {"status": "replay", "result": result} - elif interaction is not None: - answer = self.service.reusable_clarification_answer(tx, interaction) - if answer is None: - answer = self.service.clarification_budget_result(tx) - if answer is not None: - execution.status = "SUCCEEDED" - execution.result = self.cipher.seal(answer) - outcome = {"status": "replay", "result": answer} - tx.emit({"type": "human_execution", "content": { - "run_id": self.run_id, "slot": slot, "tool": tool, "status": "SUCCEEDED", - }}) - else: - self.service.request(tx, kind="CLARIFICATION", slot=slot, - action_digest=action_digest, payload=interaction) - suspended = True - elif tool not in self.allowed_tools: - payload = {"tool": tool, "arguments": redact(arguments), - "question": "请审核即将执行的工具和参数。批准仅对此次动作有效。"} - self.service.request(tx, kind="ACTION_APPROVAL", slot=slot, - action_digest=action_digest, payload=payload) - suspended = True - if not suspended and outcome is None: - # Exact saved bytes, never the caller's mutable dictionary. - execution.status = "STARTED" - outcome = {"status": "execute", "arguments": self.cipher.open(execution.arguments)} - tx.emit({"type": "human_execution", "content": { - "run_id": self.run_id, "slot": slot, "tool": tool, "status": "STARTED", - }}) - if suspended: - if not self.live_resume: - raise AttemptSuspended() - self._wait_until_ready() - if steering_requested: - return {"status": "steered"} - return self.dispatch(slot, tool, arguments, interaction=interaction) - return outcome - - def receipt(self, slot, result, *, uncertain=False): - # Flush so the DB event order is chunks → human_execution. - self.flush_chunks_until_idle() - with self.transaction(receipt=True) as tx: - execution = tx.execution(slot) - if execution is None or execution.status != "STARTED": - raise RecoveryRequired("Execution receipt does not match a started action") - execution.status = "UNKNOWN" if uncertain else "SUCCEEDED" - execution.result = None if uncertain else self.cipher.seal(result) - tx.emit({"type": "human_execution", "content": { - "run_id": self.run_id, "slot": slot, "tool": execution.tool, "status": execution.status, - }}) - - def save_plan(self, plan): - with self.transaction() as tx: - if self.cipher.open(tx.run.plan) != plan: - tx.run.plan = self.cipher.seal(plan) - tx.run.plan_version += 1 - - def load_plan(self): - with self.transaction() as tx: - return self.cipher.open(tx.run.plan) - - def emit_chunk(self, chunk): - self.emit_chunks([chunk]) - - def emit_chunks(self, chunks): - with self.transaction(receipt=True) as tx: - for chunk in chunks: - tx.emit({"chunk_cipher": self.cipher.seal(chunk)}) - - def visible_guidance(self): - """Return accepted composer input for the normal stream/history presentation path.""" - with self.transaction(receipt=True) as tx: - requests = sorted(tx.requests(), key=lambda item: (item.create_time, item.request_id)) - return [ - {"request_id": item.request_id, "text": self.cipher.open(item.decision)["text"], - "created_at": item.create_time.replace(tzinfo=timezone.utc).isoformat()} - for item in requests if item.kind == "USER_STEERING" and item.status == "DECIDED" - and self.cipher.open(item.payload).get("source") == "composer" - ] - - def finish(self, outcome): - """Write the terminal run status, flushing buffered chunks first. - - A flush failure must not prevent the terminal status row. - """ - try: - self.flush_chunks_until_idle() - except Exception: - pass - with self.transaction(receipt=True) as tx: - if tx.run.status in {"STOPPED", "EXPIRED"}: - return - if tx.run.status == "READY" and outcome == "waiting_human": - return - tx.run.status = outcome.upper() - if tx.run.status != "WAITING_HUMAN": - for request in tx.requests(): - if request.status == "PENDING": - request.status = "CANCELLED" - tx.emit({"type": "human_run", "content": {"run_id": self.run_id, "status": tx.run.status}}) diff --git a/backend/services/human_interaction/service.py b/backend/services/human_interaction/service.py deleted file mode 100644 index 4e5f1caba2..0000000000 --- a/backend/services/human_interaction/service.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Owner-scoped request lifecycle; no imports from Agent, NL2Agent or automation services.""" - -import json -from datetime import timedelta -from uuid import uuid4 - -from database.human_interaction_db import ( - ACTIVE_STATUSES, - HumanInteractionRepository, - utcnow, -) -from database.human_interaction_models import HumanRequest, HumanRun -from nexent.core.human_interaction.clarification import ( - ClarificationAnswer, - format_clarification_answers, -) - -from .crypto import PayloadCipher -from .models import DecisionCommand, InteractionError, SteeringCommand, digest, redact -from .persistence import validate_record - -TERMINAL_STATUSES = {"COMPLETED", "FAILED", "STOPPED", "EXPIRED", "RECOVERY_REQUIRED"} - - -def validate_interaction_answer(payload: dict, text: str | None) -> None: - if not text or not text.strip(): - raise InteractionError("An answer is required", 422) - options = payload.get("options") or [] - if options and text not in options and payload.get("allow_other") is not True: - raise InteractionError("Choose one of the registered options", 422) - - -def clarification_signature(payload: dict) -> str: - """Build a stable identity for one clarification within a run.""" - if "questions" in payload: - return digest({"questions": payload["questions"]}) - return digest({ - "question": str(payload.get("question") or "").strip(), - "options": payload.get("options") or [], - "allow_other": payload.get("allow_other") is not False, - }) - - -class HumanInteractionService: - def __init__(self, repository: HumanInteractionRepository, cipher: PayloadCipher, wait_seconds=86400): - self.repository = repository - self.repository.validator = validate_record - self.cipher = cipher - self.wait_seconds = wait_seconds - - @staticmethod - def require(tx): - if tx is None: - raise InteractionError("Human interaction run was not found", 404) - return tx.run - - def create(self, tenant_id, user_id, conversation_id, payload, *, ready=True): - run_id = str(uuid4()) - run = HumanRun( - run_id=run_id, tenant_id=tenant_id, user_id=user_id, conversation_id=conversation_id, - status="READY" if ready else "INITIALIZING", request_payload=self.cipher.seal(payload), - plan_version=0, fence=0, pause_requested=0, event_seq=0, - ) - with self.repository.creation(run) as tx: - if not tx.conversation_exists(): - raise InteractionError("Conversation was not found for this owner", 404) - if tx.active_run_exists(): - raise InteractionError("This conversation already has an active human interaction run") - if tx.run_id_exists(): - raise InteractionError("This run identity already exists") - tx.add(run) - return run_id - - def initialized(self, run_id, tenant_id, user_id, *, succeeded): - with self.repository.transaction(run_id, tenant_id, user_id) as tx: - run = self.require(tx) - if run.status != "INITIALIZING": - raise InteractionError("Run initialization was interrupted") - run.status = "READY" if succeeded else "FAILED" - - def _project_request(self, request, run_id): - payload = self.cipher.open(request.payload) - return {"request_id": request.request_id, "run_id": run_id, "kind": request.kind, - "status": request.status, "version": request.version, "digest": request.digest, - "expires_at": request.expires_at.isoformat(), "payload": redact(payload)} - - def _expire(self, tx): - expired = False - for request in tx.requests(): - if request.status == "PENDING" and request.expires_at <= utcnow(): - request.status = "EXPIRED" - expired = True - if expired: - tx.run.status = "EXPIRED" - tx.emit({"type": "human_run", "content": {"run_id": tx.run.run_id, "status": "EXPIRED"}}) - return expired - - def snapshot(self, run_id, tenant_id, user_id): - """Writer-path snapshot: takes a lock and may expire stale requests. - - Kept for the SSE stream's initial handshake where a terminal-status - request needs a lock-held check. Prefer :meth:`light_snapshot` for - read-only callers. - """ - with self.repository.transaction(run_id, tenant_id, user_id) as tx: - run = self.require(tx) - self._expire(tx) - return self._build_snapshot(run, tx) - - def light_snapshot(self, run_id, tenant_id, user_id): - """Read-only snapshot: no lock, no writes, no expiration scan.""" - with self.repository.read_only(run_id, tenant_id, user_id) as tx: - if tx is None: - raise InteractionError("Human interaction run was not found", 404) - return self._build_snapshot(tx.run, tx) - - def _build_snapshot(self, run, tx): - return {"run_id": run.run_id, "conversation_id": run.conversation_id, "status": run.status, - "event_seq": run.event_seq, "pause_requested": bool(run.pause_requested), - "attempt_active": bool(run.lock_until and run.lock_until > utcnow()), - "requests": [self._project_request(item, run.run_id) for item in tx.requests() if item.status == "PENDING"]} - - def request(self, tx, *, kind, slot, action_digest, payload): - request = HumanRequest( - request_id=str(uuid4()), run_record_id=tx.run.run_record_id, kind=kind, status="PENDING", version=1, - slot=slot, digest=action_digest, payload=self.cipher.seal(payload), - expires_at=utcnow() + timedelta(seconds=self.wait_seconds), - ) - tx.add(request) - tx.run.status = "WAITING_HUMAN" - tx.emit({"type": "human_interaction", "content": self._project_request(request, tx.run.run_id)}) - return request - - def reusable_clarification_answer(self, tx, payload): - """Return the latest answer for an identical clarification in this run.""" - signature = clarification_signature(payload) - decided = sorted( - (item for item in tx.requests() if item.kind == "CLARIFICATION" and item.status == "DECIDED"), - key=lambda item: item.create_time, - reverse=True, - ) - for request in decided: - if clarification_signature(self.cipher.open(request.payload)) != signature: - continue - decision = self.cipher.open(request.decision) - if decision and decision.get("decision") == "answer": - return self.clarification_result(request, decision) - return None - - def clarification_budget_result(self, tx): - """One card per run, including cancelled cards, regardless of rewording.""" - previous = [item for item in tx.requests() if item.kind == "CLARIFICATION"] - if not previous: - return None - answers = [] - for item in previous: - decision = self.cipher.open(item.decision) if item.decision else None - if decision and decision.get("decision") == "answer": - answers.append(self.clarification_result(item, decision)) - return json.dumps({ - "status": "clarification_limit_reached", - "previous_answers": answers, - "instruction": ( - "Do not ask more questions in this run, including reworded questions. " - "Use the supplied answers and user guidance to finish the task. " - "For nonessential gaps use explicit reasonable assumptions. If a critical fact remains " - "unknown, explain the limitation without inventing it or opening another card." - ), - }, ensure_ascii=False) - - def clarification_result(self, request, decision): - payload = self.cipher.open(request.payload) - if "questions" in payload: - answers = [ClarificationAnswer.model_validate(item) for item in decision["answers"]] - return format_clarification_answers(payload, answers) - return decision["text"] - - def decide(self, run_id, request_id, tenant_id, user_id, command: DecisionCommand): - error = None - with self.repository.transaction(run_id, tenant_id, user_id) as tx: - run = self.require(tx) - request = next((item for item in tx.requests() if item.request_id == request_id), None) - if request is None: - raise InteractionError("Human interaction request was not found", 404) - command_data = command.model_dump() - if command.answers is None: - # Preserve idempotent retries of decisions saved before structured forms existed. - command_data.pop("answers") - command_digest = digest(command_data) - if request.idempotency_key == command.idempotency_key: - if request.decision_digest != command_digest: - raise InteractionError("Idempotency key was used with a different decision") - return {"run_id": run_id, "request_id": request_id, "accepted": True} - if self._expire(tx): - error = InteractionError("Human interaction request has expired", 410) - elif (request.status != "PENDING" or run.status != "WAITING_HUMAN" - or command.version != request.version or command.digest != request.digest): - error = InteractionError("The request is no longer pending or its version has changed") - else: - allowed = {"CLARIFICATION": {"answer"}, "ACTION_APPROVAL": {"approve", "reject"}, - "USER_STEERING": {"steer"}}[request.kind] - if command.decision not in allowed: - raise InteractionError("Decision is not valid for this request kind", 422) - if command.decision in {"answer", "steer"}: - payload = self.cipher.open(request.payload) - if request.kind == "CLARIFICATION" and "questions" in payload: - if command.text is not None or command.answers is None: - raise InteractionError("Submit structured answers for this clarification", 422) - try: - format_clarification_answers(payload, command.answers) - except ValueError as exc: - raise InteractionError(str(exc), 422) from exc - else: - if command.answers is not None: - raise InteractionError("This request requires a text response", 422) - validate_interaction_answer(payload, command.text) - elif command.answers is not None: - raise InteractionError("Structured answers are only valid for clarification", 422) - request.status = "DECIDED" - request.idempotency_key = command.idempotency_key - request.decision_digest = command_digest - request.decision = self.cipher.seal(command.model_dump()) - run.status = "READY" - tx.emit({"type": "human_decision", "content": { - "run_id": run_id, "request_id": request_id, "status": "DECIDED", - }}) - if error: - raise error - return {"run_id": run_id, "request_id": request_id, "accepted": True} - - def steer(self, run_id, tenant_id, user_id, command: SteeringCommand): - """Deliver each composer message once while allowing successive guidance in the same run.""" - with self.repository.transaction(run_id, tenant_id, user_id) as tx: - run = self.require(tx) - fingerprint = digest(command.model_dump()) - for item in tx.requests(): - if item.kind != "USER_STEERING" or self.cipher.open(item.payload).get("source") != "composer": - continue - if item.idempotency_key == command.message_id: - if item.decision_digest != fingerprint: - raise InteractionError("Idempotency key was used with different guidance") - return {"accepted": True, "run_id": run_id, "request_id": item.request_id} - saved = self.cipher.open(run.request_payload) - if run.status not in {"READY", "RUNNING", "WAITING_HUMAN"} or saved.get("steering_closed"): - raise InteractionError("This run has finished accepting guidance; keep the message queued") - if any(item.status == "PENDING" and item.expires_at <= utcnow() for item in tx.requests()): - raise InteractionError("The pending interaction has expired", 410) - for item in tx.requests(): - if item.status == "PENDING": - item.status = "CANCELLED" - tx.flush() - request = HumanRequest( - request_id=str(uuid4()), run_record_id=run.run_record_id, kind="USER_STEERING", status="DECIDED", version=1, - slot=f"composer:{command.message_id}", digest=fingerprint, - payload=self.cipher.seal({"source": "composer"}), - decision=self.cipher.seal({"decision": "steer", "text": command.text}), - idempotency_key=command.message_id, decision_digest=fingerprint, - expires_at=utcnow() + timedelta(seconds=self.wait_seconds), - ) - tx.add(request) - run.pause_requested = 0 - if run.status == "WAITING_HUMAN": - run.status = "READY" - tx.emit({"type": "human_decision", "content": { - "run_id": run_id, "request_id": request.request_id, "status": "DECIDED", - }}) - return {"accepted": True, "run_id": run_id, "request_id": request.request_id} - - def _request_steering(self, tx): - for request in tx.requests(): - if request.status == "PENDING": - request.status = "CANCELLED" - tx.flush() - tx.run.pause_requested = 0 - self.request(tx, kind="USER_STEERING", slot=f"steering:{tx.run.event_seq}", - action_digest=digest([tx.run.run_id, tx.run.event_seq, "steering"]), - payload={"question": "请提供新的意见或约束,继续执行当前任务。", "options": []}) - - def control(self, run_id, tenant_id, user_id, action): - with self.repository.transaction(run_id, tenant_id, user_id) as tx: - run = self.require(tx) - if action == "terminate": - if run.status not in {"COMPLETED", "FAILED", "STOPPED", "EXPIRED"}: - run.status = "STOPPED" - run.pause_requested = 0 - for request in tx.requests(): - if request.status == "PENDING": - request.status = "CANCELLED" - elif action == "pause": - if run.status not in ACTIVE_STATUSES or run.status == "RECOVERY_REQUIRED": - raise InteractionError("This run cannot be paused") - pending = [item for item in tx.requests() if item.status == "PENDING"] - if run.status == "WAITING_HUMAN": - if not any(item.kind == "USER_STEERING" for item in pending): - self._request_steering(tx) - else: - run.pause_requested = 1 - else: - raise InteractionError("Unsupported control action", 422) - tx.emit({"type": "human_run", "content": { - "run_id": run_id, "status": run.status, "pause_requested": bool(run.pause_requested), - }}) - return self.snapshot(run_id, tenant_id, user_id) - - def expire_waiting(self): - for run_id in self.repository.waiting_ids(): - with self.repository.transaction(run_id) as tx: - if tx is not None: - self._expire(tx) diff --git a/backend/services/human_interaction/stream.py b/backend/services/human_interaction/stream.py deleted file mode 100644 index a167c631b9..0000000000 --- a/backend/services/human_interaction/stream.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Merge accepted guidance into the existing ordered stream and message-unit persistence.""" - -import asyncio -import json -from contextlib import suppress - -from nexent.core.concurrency import run_blocking - - -async def stream_with_guidance(source, port, *, poll_seconds=0.25): - """Keep the original iterator alive while input arrives during a model/tool wait.""" - seen = set() - iterator = aiter(source) - pending = None - next_poll = 0.0 - loop = asyncio.get_running_loop() - try: - while True: - if pending is None: - pending = asyncio.create_task(anext(iterator)) - await asyncio.wait({pending}, timeout=max(0, next_poll - loop.time())) - ended = pending.done() and isinstance(pending.exception(), StopAsyncIteration) - if loop.time() >= next_poll or ended: - for item in await run_blocking( - "hitl-port-visible_guidance", port.visible_guidance, lane="control-io", owner=__name__, - ): - if item["request_id"] not in seen: - seen.add(item["request_id"]) - yield json.dumps({"type": "user_steering", "content": json.dumps(item, ensure_ascii=False)}, - ensure_ascii=False) - next_poll = loop.time() + poll_seconds - if pending.done(): - try: - chunk = pending.result() - except StopAsyncIteration: - break - pending = None - yield chunk - finally: - if pending is not None and not pending.done(): - pending.cancel() - with suppress(asyncio.CancelledError): - await pending - await iterator.aclose() diff --git a/backend/services/northbound_service.py b/backend/services/northbound_service.py index 4a75b2c59f..158367f495 100644 --- a/backend/services/northbound_service.py +++ b/backend/services/northbound_service.py @@ -384,7 +384,6 @@ async def start_streaming_chat( tool_params: Optional[ToolParamsRequest] = None, model_id: Optional[int] = None, idempotency_key: Optional[str] = None, - enable_hitl: bool = False, ) -> StreamingResponse: new_conversation_data: Optional[Dict[str, Any]] = None try: @@ -448,7 +447,6 @@ async def start_streaming_chat( version_no=latest_version_no, metadata=metadata, enable_automation_tool=False, - enable_hitl=enable_hitl, ) agent_request.__dict__["_runtime_metadata_entrypoint"] = "northbound" diff --git a/backend/services/runtime_proxy_service.py b/backend/services/runtime_proxy_service.py index cde7e8dae7..f3dc099e1d 100644 --- a/backend/services/runtime_proxy_service.py +++ b/backend/services/runtime_proxy_service.py @@ -213,52 +213,6 @@ async def body_iterator() -> AsyncIterator[bytes]: ) -async def forward_human_interaction( - method: str, - path: str, - user_id: str, - tenant_id: str, - *, - payload: dict | None = None, -) -> dict | None: - """Forward a server-built HITL path using the authenticated northbound owner.""" - try: - async with create_httpx_client( - headers=_authorization_headers(user_id, tenant_id), timeout=_REQUEST_TIMEOUT, - ) as client: - response = await client.request( - method, _runtime_url(f"/agent/internal/northbound/human-interactions/{path}"), json=payload, - ) - except httpx.TimeoutException as exc: - raise RuntimeServiceTimeoutError("Runtime human interaction request timed out") from exc - except httpx.RequestError as exc: - raise RuntimeServiceUnavailableError(_RUNTIME_SERVICE_UNAVAILABLE_MESSAGE) from exc - - if response.status_code >= 400: - raise RuntimeUpstreamError( - status_code=response.status_code, content=response.content, headers=_forwarded_headers(response.headers), - ) - try: - result = response.json() - except ValueError as exc: - raise RuntimeServiceUnavailableError("Runtime human interaction response is not valid JSON") from exc - if result is None and path.startswith("conversation/"): - return None - if not isinstance(result, dict): - raise RuntimeServiceUnavailableError("Runtime human interaction response is not a JSON object") - return result - - -async def forward_human_interaction_events( - run_id: str, user_id: str, tenant_id: str, *, after_event: int = 0, -) -> StreamingResponse: - """Subscribe to an existing run without creating or persisting a chat message.""" - return await _forward_runtime_stream( - "GET", f"/agent/internal/northbound/human-interactions/{run_id}/events", user_id, tenant_id, - params={"after_event": after_event}, - ) - - async def forward_agent_stop( conversation_id: int, user_id: str, diff --git a/backend/utils/agent_stream_utils.py b/backend/utils/agent_stream_utils.py index 7e0a07483f..1f8cd0ffd4 100644 --- a/backend/utils/agent_stream_utils.py +++ b/backend/utils/agent_stream_utils.py @@ -89,8 +89,10 @@ def extract_skill_file_upload_payloads(content: str) -> list[dict]: ] -def serialize_stream_unit_content(data: Dict[str, Any], content: str) -> str: - """Preserve tool metadata in the existing message-unit content column.""" +def serialize_stream_unit_content(data: Dict[str, Any], content: Any) -> str: + """Preserve structured content and tool metadata in the text unit column.""" + if data.get("type") == "human_interaction" and not isinstance(content, str): + return json.dumps(content, ensure_ascii=False) if data.get("type") not in {"tool", "tool-call"}: return content diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index 840d902bff..b64e85c23b 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -161,7 +161,7 @@ def _build_execution_flow_text( else: lines.append(" - 根据格式规范正确调用工具") lines.append(" - 考虑到代码执行与展示用户代码的区别,使用'代码'表达运行代码,使用'代码'表达展示代码") - lines.append(" - 每个模型执行轮次最多输出一个'...'代码块;如需调用多个工具,请将调用写在同一个代码块内,并等待本轮执行结果后再生成下一轮代码") + lines.append(" - 每个模型执行轮次优先输出一个完整的'...'代码块;如确需多个代码块,必须相邻输出且块间只能有空白,它们会被合并为同一个动作执行。第一个代码块开始后及最后一个代码块之后不得输出正文") lines.append(" - 注意运行的代码不会被用户看到,所以如果用户需要看到代码,你需要使用'代码'表达展示代码。") lines.append("") lines.append("3. 自验证:") @@ -169,7 +169,7 @@ def _build_execution_flow_text( lines.append(" - 如果自验证提示存在错误、证据不足、参数不完整或结果不可靠,必须优先修正、补充证据、重新调用工具,或清晰说明无法完成的部分。") lines.append(" - 最终回答只有在自验证通过后才会展示给用户;如果系统返回 Verification feedback,请根据该反馈继续修正,不要忽略。") lines.append("") - lines.append("在思考结束后,当你认为可以回答用户问题,必须在唯一的 `...` 代码块中调用 `final_answer(...)`;禁止输出裸文本最终回答。") + lines.append("在思考结束后,当你认为可以回答用户问题,必须在最后一个 `...` 代码块的最后一条顶层语句中调用 `final_answer(...)`;禁止输出裸文本最终回答或在其后继续执行动作。") lines.append("") lines.append("生成最终回答时,你需要遵循以下规范:") lines.append("1. Markdown格式要求:") @@ -233,7 +233,7 @@ def _build_execution_flow_text( else: lines.append(" - Call tools correctly according to format specifications") lines.append(" - To distinguish between code execution and displaying user code, use 'code' for executing code and 'code' for displaying code") - lines.append(" - Output at most one executable '...' block per model step. Put multiple tool calls inside that one block when needed, then wait for its execution result before producing the next block.") + lines.append(" - Prefer one complete executable '...' block per model step. If multiple blocks are necessary, emit them consecutively with whitespace only between them; they execute together as one action. Put no prose after the first block starts or after the final block.") lines.append(" - Note that executed code is not visible to users. If users need to see the code, use 'code' for displaying code.") lines.append("") lines.append("3. Self-verification:") @@ -241,7 +241,7 @@ def _build_execution_flow_text( lines.append(" - If verification reports errors, insufficient evidence, incomplete parameters, or unreliable results, you must repair the issue, gather more evidence, call tools again, or clearly state what cannot be completed.") lines.append(" - The final answer is shown to the user only after verification passes. If the system returns Verification feedback, continue revising based on that feedback.") lines.append("") - lines.append("After thinking, when you can answer the user, call `final_answer(...)` inside the single `...` action block. Never return a bare-text final answer.") + lines.append("After thinking, when you can answer the user, call `final_answer(...)` as the last top-level statement in the final `...` block. Never return a bare-text final answer or execute another action afterward.") lines.append("") lines.append("When generating the final answer, you need to follow these specifications:") lines.append("1. **Markdown Format Requirements**:") @@ -306,7 +306,7 @@ def _build_code_norms_text( """ if language == "zh": lines = ["### python代码规范"] - lines.append("1. 如果认为是需要执行的代码,使用'代码'格式,并且每个执行轮次最多输出一个'...'代码块;如果需要多个工具调用,将它们写在同一个代码块中。如果是不需要执行仅用于展示的代码,使用'代码'格式,其中语言类型例如python、java、javascript等;") + lines.append("1. 如果认为是需要执行的代码,使用'代码'格式;每个执行轮次优先使用一个代码块,如需多个代码块则必须相邻且块间只能有空白,所有代码块会合并为同一个动作执行。如果是不需要执行仅用于展示的代码,使用'代码'格式,其中语言类型例如python、java、javascript等;") lines.append("2. 只使用已定义的变量,变量将在多次调用之间持续保持;") lines.append("3. 使用\"print()\"函数让下一次的模型调用看到对应变量信息;") lines.append("4. 正确使用工具/助手的入参,使用关键字参数,不要用字典形式;") @@ -320,7 +320,7 @@ def _build_code_norms_text( lines.append("12. 不要放弃!你负责解决任务,而不是提供解决方向。") else: lines = ["### Python Code Specifications"] - lines.append("1. If code needs to be executed, use 'code' and output at most one executable '...' block per step; place multiple tool calls inside that single block when needed. For display-only code, use 'code', where language_type can be python, java, javascript, etc;") + lines.append("1. If code needs to be executed, use 'code' and prefer one executable block per step. If multiple blocks are needed, emit them consecutively with whitespace only between them; all blocks execute together as one action. For display-only code, use 'code', where language_type can be python, java, javascript, etc;") lines.append("2. Only use defined variables, variables will persist between multiple calls;") lines.append("3. Use \"print()\" function to let the next model call see corresponding variable information;") lines.append("4. Use tool/agent input parameters correctly, use keyword arguments, not dictionary format;") diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 184b05249c..0ec5c6371b 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -631,27 +631,6 @@ generate_supabase_keys() { fi } -configure_human_interaction() { - export HITL_ENABLED="${HITL_ENABLED:-true}" - export HITL_ACCEPT_NEW_RUNS="${HITL_ACCEPT_NEW_RUNS:-true}" - export HITL_TOOL_APPROVAL_ENABLED="${HITL_TOOL_APPROVAL_ENABLED:-false}" - export HITL_WAIT_SECONDS="${HITL_WAIT_SECONDS:-86400}" - export HITL_MAX_CONCURRENCY="${HITL_MAX_CONCURRENCY:-2}" - - update_env_var "HITL_ENABLED" "$HITL_ENABLED" - update_env_var "HITL_ACCEPT_NEW_RUNS" "$HITL_ACCEPT_NEW_RUNS" - update_env_var "HITL_TOOL_APPROVAL_ENABLED" "$HITL_TOOL_APPROVAL_ENABLED" - update_env_var "HITL_WAIT_SECONDS" "$HITL_WAIT_SECONDS" - update_env_var "HITL_MAX_CONCURRENCY" "$HITL_MAX_CONCURRENCY" - - if [ "$HITL_ENABLED" = "true" ] && [ -z "${HITL_ENCRYPTION_KEY:-}" ]; then - HITL_ENCRYPTION_KEY=$(openssl rand -base64 32 | tr '/+' '_-' | tr -d '[:space:]') - export HITL_ENCRYPTION_KEY - update_env_var "HITL_ENCRYPTION_KEY" "$HITL_ENCRYPTION_KEY" - echo " ✅ Human interaction encryption key generated" - fi -} - validate_elasticsearch_api_key() { local api_key="$1" local http_code @@ -1911,15 +1890,6 @@ main_deploy() { exit 1 } - configure_human_interaction || { - if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then - echo "❌ 人在回路配置生成失败" - else - echo "❌ Human interaction configuration failed" - fi - exit 1 - } - reconcile_sandbox_container || exit 1 # Deploy infrastructure services diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 58f7d817e7..20ef1f9dbd 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -350,15 +350,3 @@ LOG_MAX_BYTES=52428800 # Number of backup files to retain LOG_BACKUP_COUNT=30 - -# ===== Human Interaction Configuration ===== -# In-loop clarification is enabled by default for normal chat. Deployment scripts -# generate and persist HITL_ENCRYPTION_KEY when it is empty. -HITL_ENABLED=true -HITL_ACCEPT_NEW_RUNS=true -# Keep action approval independent from clarification. When enabled, every -# non-built-in Agent tool requires explicit review before execution. -HITL_TOOL_APPROVAL_ENABLED=false -HITL_ENCRYPTION_KEY= -HITL_WAIT_SECONDS=3600 -HITL_MAX_CONCURRENCY=100 diff --git a/deploy/env/hitl.env.example b/deploy/env/hitl.env.example deleted file mode 100644 index c72e584639..0000000000 --- a/deploy/env/hitl.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# Durable in-loop clarification is enabled by default. Deployment scripts generate -# and persist HITL_ENCRYPTION_KEY when the primary deploy environment leaves it empty. -HITL_ENABLED=true -HITL_ACCEPT_NEW_RUNS=true -HITL_TOOL_APPROVAL_ENABLED=false -HITL_ENCRYPTION_KEY= -HITL_WAIT_SECONDS=3600 -HITL_MAX_CONCURRENCY=100 diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index dd3df85df0..5614572a4a 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -470,12 +470,6 @@ render_k8s_runtime_config_values() { printf ' url: %s\n' "$(yaml_quote "$(env_or_default REDIS_URL "redis://nexent-redis:6379/0")")" printf ' backendUrl: %s\n' "$(yaml_quote "$(env_or_default REDIS_BACKEND_URL "redis://nexent-redis:6379/1")")" printf ' port: %s\n' "$(yaml_quote "$(env_or_default REDIS_PORT "6379")")" - echo " humanInteraction:" - printf ' enabled: %s\n' "$(yaml_quote "${HITL_ENABLED:-true}")" - printf ' acceptNewRuns: %s\n' "$(yaml_quote "${HITL_ACCEPT_NEW_RUNS:-true}")" - printf ' toolApprovalEnabled: %s\n' "$(yaml_quote "${HITL_TOOL_APPROVAL_ENABLED:-false}")" - printf ' waitSeconds: %s\n' "$(yaml_quote "${HITL_WAIT_SECONDS:-86400}")" - printf ' maxConcurrency: %s\n' "$(yaml_quote "${HITL_MAX_CONCURRENCY:-2}")" echo " minio:" printf ' endpoint: %s\n' "$(yaml_quote "$(env_or_default MINIO_ENDPOINT "http://nexent-minio:9000")")" printf ' region: %s\n' "$(yaml_quote "$(env_or_default MINIO_REGION "cn-north-1")")" @@ -1171,32 +1165,6 @@ load_existing_elasticsearch_api_key() { return 0 } -load_existing_hitl_encryption_key() { - local existing_key - existing_key="$(get_existing_secret_value "HITL_ENCRYPTION_KEY")" || return 1 - [ -n "$existing_key" ] || return 1 - HITL_ENCRYPTION_KEY="$existing_key" - return 0 -} - -configure_human_interaction() { - HITL_ENABLED="${HITL_ENABLED:-true}" - HITL_ACCEPT_NEW_RUNS="${HITL_ACCEPT_NEW_RUNS:-true}" - HITL_TOOL_APPROVAL_ENABLED="${HITL_TOOL_APPROVAL_ENABLED:-false}" - HITL_WAIT_SECONDS="${HITL_WAIT_SECONDS:-86400}" - HITL_MAX_CONCURRENCY="${HITL_MAX_CONCURRENCY:-2}" - - if [ "$HITL_ENABLED" != "true" ] || [ -n "${HITL_ENCRYPTION_KEY:-}" ]; then - return 0 - fi - if load_existing_hitl_encryption_key; then - echo "Reusing existing human interaction encryption key from Kubernetes secret." - return 0 - fi - HITL_ENCRYPTION_KEY=$(openssl rand -base64 32 | tr '/+' '_-' | tr -d '[:space:]') - echo "Human interaction encryption key generated for Kubernetes secret." -} - # Generate Supabase secrets (only for full version) generate_supabase_secrets() { if [ "$DEPLOYMENT_VERSION" != "full" ]; then @@ -1340,7 +1308,7 @@ render_runtime_secret_values() { gotrue_db_url="$(env_or_default GOTRUE_DB_DATABASE_URL "postgres://supabase_auth_admin:${supabase_postgres_password}@$(env_or_default SUPABASE_POSTGRES_HOST "nexent-supabase-db"):$(env_or_default SUPABASE_POSTGRES_PORT "5436")/$(env_or_default SUPABASE_POSTGRES_DB "supabase")?search_path=auth&sslmode=disable")" env_checksum="$(deployment_env_values_checksum)" sql_checksum="$(sql_files_checksum)" - supabase_secret_checksum="$(deployment_sha256_string "jwt=${JWT_SECRET:-}|secretKeyBase=${SECRET_KEY_BASE:-}|vault=${VAULT_ENC_KEY:-}|anon=${SUPABASE_ANON_KEY:-}|service=${SUPABASE_SERVICE_ROLE_KEY:-}|postgres=${supabase_postgres_password}|gotrue=${gotrue_db_url}|hitl=${HITL_ENCRYPTION_KEY:-}")" + supabase_secret_checksum="$(deployment_sha256_string "jwt=${JWT_SECRET:-}|secretKeyBase=${SECRET_KEY_BASE:-}|vault=${VAULT_ENC_KEY:-}|anon=${SUPABASE_ANON_KEY:-}|service=${SUPABASE_SERVICE_ROLE_KEY:-}|postgres=${supabase_postgres_password}|gotrue=${gotrue_db_url}")" { echo "global:" @@ -1353,7 +1321,6 @@ render_runtime_secret_values() { echo " secrets:" printf ' elasticsearchApiKey: %s\n' "$(yaml_quote "$(env_or_default ELASTICSEARCH_API_KEY "")")" printf ' postgresPassword: %s\n' "$(yaml_quote "$(env_or_default NEXENT_POSTGRES_PASSWORD "nexent@4321")")" - printf ' hitlEncryptionKey: %s\n' "$(yaml_quote "${HITL_ENCRYPTION_KEY:-}")" echo " minio:" printf ' rootUser: %s\n' "$(yaml_quote "$(env_or_default MINIO_ROOT_USER "nexent")")" printf ' rootPassword: %s\n' "$(yaml_quote "$(env_or_default MINIO_ROOT_PASSWORD "nexent@4321")")" @@ -1512,7 +1479,6 @@ apply() { # Step 6: Prepare application-only secrets after infrastructure is healthy. generate_supabase_secrets - configure_human_interaction if [ "${DEPLOYMENT_REFRESH_ES_KEY:-false}" != "true" ] && [ "${DEPLOYMENT_ROTATE_SECRETS:-false}" != "true" ]; then if [ -n "${ELASTICSEARCH_API_KEY:-}" ]; then echo "Using ELASTICSEARCH_API_KEY from deploy/env/.env." diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml index ad965dc7f9..c5c2d9afc4 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml @@ -51,12 +51,6 @@ data: RUNTIME_COMPLETED_TTL_SECONDS: {{ .Values.config.runtimeState.completedTtlSeconds | quote }} RUNTIME_CANCEL_POLL_INTERVAL_SECONDS: {{ .Values.config.runtimeState.cancelPollIntervalSeconds | quote }} - # Human Interaction - HITL_ENABLED: {{ .Values.config.humanInteraction.enabled | quote }} - HITL_ACCEPT_NEW_RUNS: {{ .Values.config.humanInteraction.acceptNewRuns | quote }} - HITL_TOOL_APPROVAL_ENABLED: {{ .Values.config.humanInteraction.toolApprovalEnabled | quote }} - HITL_WAIT_SECONDS: {{ .Values.config.humanInteraction.waitSeconds | quote }} - HITL_MAX_CONCURRENCY: {{ .Values.config.humanInteraction.maxConcurrency | quote }} NORTHBOUND_IDEMPOTENCY_TTL_SECONDS: {{ .Values.config.northbound.idempotencyTtlSeconds | quote }} NORTHBOUND_RATE_LIMIT_ENABLED: {{ .Values.config.northbound.rateLimitEnabled | quote }} NORTHBOUND_RATE_LIMIT_PER_MINUTE: {{ .Values.config.northbound.rateLimitPerMinute | quote }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/secrets.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/secrets.yaml index df01ba2e65..525cded27d 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/secrets.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/secrets.yaml @@ -13,10 +13,6 @@ data: NEXENT_POSTGRES_PASSWORD: {{ .Values.secrets.postgresPassword | b64enc | quote }} - {{- if .Values.secrets.hitlEncryptionKey }} - HITL_ENCRYPTION_KEY: {{ .Values.secrets.hitlEncryptionKey | b64enc | quote }} - {{- end }} - MINIO_ROOT_USER: {{ .Values.secrets.minio.rootUser | b64enc | quote }} MINIO_ROOT_PASSWORD: {{ .Values.secrets.minio.rootPassword | b64enc | quote }} MINIO_ACCESS_KEY: {{ .Values.secrets.minio.accessKey | b64enc | quote }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml index 78d4203683..f585004e6f 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml @@ -50,12 +50,6 @@ config: cancelTtlSeconds: "86400" completedTtlSeconds: "300" cancelPollIntervalSeconds: "1.0" - humanInteraction: - enabled: "true" - acceptNewRuns: "true" - toolApprovalEnabled: "false" - waitSeconds: "86400" - maxConcurrency: "2" northbound: idempotencyTtlSeconds: "600" rateLimitEnabled: "true" @@ -231,7 +225,6 @@ config: secrets: elasticsearchApiKey: "" postgresPassword: "nexent@4321" - hitlEncryptionKey: "" minio: rootUser: "nexent" rootPassword: "nexent@4321" diff --git a/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql b/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql new file mode 100644 index 0000000000..d0109fea9b --- /dev/null +++ b/deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql @@ -0,0 +1,8 @@ +-- Deploy only after all processes using the retired interaction engine have stopped. +-- Ordinary conversation messages and units are intentionally preserved. +DROP TABLE IF EXISTS nexent.human_event_t; +DROP TABLE IF EXISTS nexent.human_execution_t; +DROP TABLE IF EXISTS nexent.human_request_t; +DROP TABLE IF EXISTS nexent.human_run_t; +-- This function is used exclusively by the four tables removed above. +DROP FUNCTION IF EXISTS nexent.human_interaction_audit_timestamp(); diff --git a/doc/docs/en/integration/integration-out/northbound-api.md b/doc/docs/en/integration/integration-out/northbound-api.md index 74e0728c0a..46660cda88 100644 --- a/doc/docs/en/integration/integration-out/northbound-api.md +++ b/doc/docs/en/integration/integration-out/northbound-api.md @@ -91,7 +91,6 @@ POST /nb/v1/chat/run |-----------|------|----------|-------------| | `agent_name` | string | Yes | Target Agent name | | `query` | string | Yes | User input content | -| `enable_hitl` | boolean | No | Enable human interaction; defaults to `false` | | `conversation_id` | integer | No | Existing conversation ID; if not provided, create new conversation | | `attachments` | array | No | Attachment list (S3 URLs or attachment metadata objects) | | `model_id` | integer | No | Model ID (overrides Agent default model) | @@ -205,116 +204,40 @@ data: {"type":"model_output_thinking","content":", please wait...","unit_index": data: {"type":"final_answer","content":"Analysis complete","unit_index":2} ``` -## Human-in-the-loop (HITL) +## Structured clarification and follow-up after stopping -The gateway entry point is `POST /api/nb/v1/chat/run`. Other paths in this document omit the shared `/api` gateway prefix; use your deployment's root path when connecting directly. All HITL endpoints use `Authorization: Bearer {access_key}`. The API key determines the user and tenant; callers cannot supply either identity. +When essential information is missing, the Agent can return a structured clarification. The ordinary completion path ends that run and releases its worker. After the stream ends, send the answers as a normal new query in the same conversation. No capability switch, pending-request lookup, decision endpoint, or execution-resume API is required. -### Start and discover capabilities +### Clarification SSE -```bash -curl -N 'https://your-nexent-domain.com/api/nb/v1/chat/run' \ - -H "Authorization: Bearer ${NEXENT_API_KEY}" \ - -H 'Content-Type: application/json' \ - -d '{"agent_name":"general-assistant","query":"Help me prepare a notice","enable_hitl":true}' -``` - -`enable_hitl` defaults to `false`. Check `GET /nb/v1/chat/human-interactions/capabilities` first: its `data` includes `enabled`, `accept_new_runs`, `tool_approval_enabled`, executor mode and form limits. When HITL is disabled or new runs are being drained, chat starts follow the existing ordinary execution path. Opting in does not force a clarification card for every query. - -The runtime creates a `run_id` for this conversation. A pending interaction puts it into `WAITING_HUMAN`; an accepted decision moves it to `READY` so the existing worker or scheduler can continue the same run. The northbound service forwards requests using an internal JWT and reuses runtime persistence, ownership checks, version checks and idempotency. - -### SSE contract - -The envelope remains `data: {"type": ..., "content": ...}`. **The card event type is `human_interaction`.** Its `content` is a JSON object, not a serialized JSON string. - -| `type` | Content and purpose | -|---|---| -| `conversation_created` | Object containing the newly created `conversation_id` | -| `human_run` | Run status; snapshots also include `conversation_id`, `event_seq`, and pending `requests` | -| `human_interaction` | A pending input or approval card | -| `human_decision` | Accepted decision: `run_id`, `request_id`, `status` | -| `human_execution` | Tool execution slot and status | -| `model_output_thinking`, `final_answer`, `token_count`, etc. | Existing runtime output, forwarded unchanged | - -The card's `content.kind` selects its behavior: - -| `kind` | `decision` | Input | -|---|---|---| -| `CLARIFICATION` | `answer` | `answers` for structured forms; `text` for legacy single-question cards | -| `ACTION_APPROVAL` | `approve` / `reject` | Approve or reject the exact frozen action | -| `USER_STEERING` | `steer` | `text` with guidance after a pause | - -Example (copy the actual card's digest when replying): +The envelope remains `data: {"type": ..., "content": ...}`. For `type="human_interaction"`, `content` is a **JSON object** containing `schema_version: 1` and `questions`. An ordinary `final_answer` also contains the complete readable questions as a text fallback. ```text -id: 12 -data: {"type":"human_interaction","content":{"request_id":"01a09fce-3a28-7860-8646-b0eec69d5f47","run_id":"01a09fce-3a28-7860-8646-b0eec69d5f46","kind":"CLARIFICATION","status":"PENDING","version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expires_at":"2026-09-15T10:00:00+00:00","payload":{"schema_version":2,"questions":[{"id":"audience","type":"single_choice","title":"Who is the notice for?","required":true,"options":[{"id":"team","label":"Internal team"},{"id":"client","label":"Clients"}],"allow_other":true}]}}} -``` - -`payload.questions` supports `text`, `single_choice`, and `multiple_choice`, with at most five questions and one clarification card per run. Text answers use strings, single-choice answers use option IDs, and multiple-choice answers use arrays of option IDs. When `allow_other` is true, `other_text` can supplement an answer. Runtime validates required fields, allowed options and duplicate answers. Question-level `type` is separate from the outer SSE event type. - -### Endpoints and decisions +data: {"type":"human_interaction","content":{"schema_version":1,"questions":[{"id":"audience","type":"single_choice","title":"Who is the notice for?","required":true,"options":[{"id":"team","label":"Internal team"},{"id":"client","label":"Clients"}],"allow_other":true,"placeholder":""}]},"unit_index":1} -The common prefix below is `/nb/v1/chat/human-interactions`: +data: {"type":"final_answer","content":"1. Who is the notice for?\n - Internal team\n - Clients\n - 其他 / Other","unit_index":2} +``` -| Method | Path | Purpose | -|---|---|---| -| GET | `/capabilities` | Read actual runtime capabilities | -| GET | `/conversation/{conversation_id}` | Latest visible run for this API user; `data=null` if none | -| GET | `/{run_id}` | Read status and pending `requests` | -| POST | `/{run_id}/requests/{request_id}/decisions` | Submit a card response | -| GET | `/{run_id}/events?after_event=12` | Subscribe to later SSE events of the same run | -| POST | `/{run_id}/pause` | Request a pause at a safe boundary; does not immediately abort an executing tool | -| POST | `/{run_id}/steer` | Add guidance and cancel any pending card | -| POST | `/{run_id}/terminate` | Stop the run and cancel pending cards | +There are at most five questions, using `text`, `single_choice`, or `multiple_choice`. Question fields include `id`, `type`, `title`, `required`, `options`, `allow_other`, and `placeholder`; each option has an `id` and `label`. Text questions have no options, and choice questions have 2–12 options. Render the form and keep submission disabled until the current stream ends. Clients rendering a valid card may hide its exactly matching text fallback; text-only clients display `final_answer`. -JSON success responses use `{"message":"success","requestId":"...","data":...}`. An accepted decision returns `data: {"run_id":"...","request_id":"...","accepted":true}`. SSE endpoints return the event stream directly. +### Send answers as the next query -Submit completed cards to the decision endpoint; **do not send the answer as a new `/chat/run` query**: +Combine the questions, readable option labels, and any additional text into an ordinary query using the actual conversation ID: ```bash -curl -X POST \ - 'https://your-nexent-domain.com/api/nb/v1/chat/human-interactions/01a09fce-3a28-7860-8646-b0eec69d5f46/requests/01a09fce-3a28-7860-8646-b0eec69d5f47/decisions' \ +curl -N 'https://your-nexent-domain.com/api/nb/v1/chat/run' \ -H "Authorization: Bearer ${NEXENT_API_KEY}" \ -H 'Content-Type: application/json' \ - -d '{ - "version":1, - "digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "idempotency_key":"answer-20260914-0001", - "decision":"answer", - "answers":[{"question_id":"audience","value":"team"}] - }' + -d '{"conversation_id":123,"agent_name":"general-assistant","query":"Answers to the previous questions: the notice is for the internal team, specifically the engineering department."}' ``` -Copy `version` and `digest` from the actual card. The JSON body must contain an `idempotency_key` of 8–100 characters. Retry a timed-out submission with the same key and identical body; reusing a key with different content returns `409`. Structured forms require `answers` without `text`. Legacy clarification and pause cards use `text`. Approvals use `approve` or `reject` without `answers`, and cannot change the tool's frozen arguments. - -For additional guidance outside a card, submit `{"message_id":"guidance-0001","text":"Continue with the revised requirements"}` to `/{run_id}/steer`. `message_id` provides idempotency for that guidance. This cancels pending cards and does not approve a pending tool action. - -A conversation may contain multiple runs and card versions, so `conversation_id` alone is insufficient for submission. Decisions bind to `run_id + request_id + version + digest`; the conversation snapshot endpoint recovers these identifiers. - -### Waiting and reconnecting - -1. Record the conversation ID, run ID, and SSE `id` of each fully consumed event. Merge cards from both `human_run.requests` and `human_interaction` by `request_id + version`. -2. Display `human_interaction` cards and update them when receiving `human_decision`. -3. `accepted=true` confirms input acceptance; subsequent output still arrives through SSE. Continue consuming the original stream if it is connected. Otherwise subscribe to `/{run_id}/events?after_event=`. -4. If `after_event` is absent, the subscription endpoint also accepts `Last-Event-ID`. Explicit `after_event` takes precedence. The default `0` replays all persisted events. Subscribing neither writes a user message nor starts another Agent run. -5. Every subscription starts with a current `human_run` snapshot without an SSE `id`, followed by persisted events after the requested cursor. **Do not advance the consumed cursor to that initial snapshot's `event_seq`**: doing so would skip output not yet consumed. Snapshots and heartbeats do not advance the client cursor. After submitting a decision, continue from the last ID consumed before submission. -6. `WAITING_HUMAN` is not completion; the subscription may remain open or close while waiting. Terminal statuses are `COMPLETED`, `FAILED`, `STOPPED`, `EXPIRED`, and `RECOVERY_REQUIRED`. The last means execution cannot automatically resume; do not blindly replay the action. - -The existing `GET /nb/v1/chat/stop/{conversation_id}` also terminates the API user's active HITL run in that conversation. +`123` is only an example. Send readable answers, not just question or option IDs. The new run reads ordinary conversation history without restoring a previous execution stack or plan cursor. On refresh, rebuild the card from its ordinary `human_interaction` message unit. Older cards are read-only, and their answers appear in the following user message. -### Errors and deployment +### Ordinary stop and compatibility -| HTTP status | Meaning | -|---|---| -| 401 | Invalid API key or internal runtime authentication | -| 404 | Run/card missing or outside the authenticated user and tenant scope | -| 409 | Card already handled, stale version/digest, or conflicting idempotency key | -| 410 | Expiration detected during this submission; a previously processed expiration may instead return `409` | -| 422 | Invalid command, answer, decision kind or event cursor | -| 503 | Runtime HITL disabled for run queries and decisions | -| 502 / 504 | Runtime unavailable / timeout | +Continue using `GET /nb/v1/chat/stop/{conversation_id}`. Success acknowledges the stop request; the run remains reserved until its worker actually exits. If the next send receives `X-Stream-Status: conflict`, preserve the draft and ask the user to send again shortly without automatically retrying. A new query after stopping can read the original task, the stop notice, and saved partial results. Stopping does not roll back external operations already performed. -Apply `deploy/sql/migrations/v2.6.0_merged_migrations.sql`, configure a valid runtime `HITL_ENCRYPTION_KEY`, and enable `HITL_ENABLED` and `HITL_ACCEPT_NEW_RUNS`. Tool approval is independently controlled by `HITL_TOOL_APPROVAL_ENABLED`. The default `native-live-v1` mode preserves the live execution state; loss of its worker can require recovery instead of automatic replay. These northbound endpoints preserve the existing execution modes' limits. +The old HITL routes, approvals, pause/resume engine, four-table runtime dependencies, and encryption-key settings have been removed. Northbound `/chat/run` returns `400` for any request containing `enable_hitl`, `hitl_run_id`, or `hitl_after_event`, including false, null, and zero values. Update Web and runtime together. Existing identity, permissions, attachment, metadata, and ordinary error contracts remain unchanged. ## Upload Conversation Attachments diff --git a/doc/docs/zh/backend/human-in-the-loop-implementation.md b/doc/docs/zh/backend/human-in-the-loop-implementation.md index 22267fe7b4..2831dca428 100644 --- a/doc/docs/zh/backend/human-in-the-loop-implementation.md +++ b/doc/docs/zh/backend/human-in-the-loop-implementation.md @@ -1,196 +1,77 @@ -# 人在回路:受控运行首版实现 +# 结构化反问与普通停止实现 -实现日期:2026-08-31。依据 `_doc/agent-human-in-the-loop-design.md` 的三项最低能力交付路径,落地普通聊天根 Agent 的主动反问、执行前审批和暂停后引导续跑。默认关闭,未部署到当前业务实例。 +本文对应 `_doc/agent-human-in-the-loop-design.md` v10,更新于 2026-09-21。旧 HITL 的审批、暂停续跑、执行检查点、调度器和专属四表运行依赖已移除。当前实现复用普通聊天的完成、停止、持久化和下一轮 query。 -本报告描述当前代码和验证边界;原设计中的全部业务整合、外部执行适配和后台 HA 目标不等同于本次已交付能力。 +## 运行行为 -## 1. 能力与边界 +普通聊天根 Agent 可输出独立的 `ask_user(questions=[...])`。解析器在 Python 执行及工具 precheck 之前识别该终结动作,仅接受字面量表单;赋值、混合业务动作、嵌套调用、动态表达式和别名引用整块拒绝,不执行其中任何业务动作。同名业务工具继续使用原名称,平台反问依次选择 `nexent_ask_user`、`nexent_ask_user_1` 等可用名称,并同步注入系统上下文。 -| 能力 | 当前行为 | -| --- | --- | -| 主动反问 | ReAct / Planning 可调用 `ask_user(question, options=None)`;回答后恢复同一 `run_id` 和原调用位置 | -| 执行前审批 | 除内置 final_answer 和本地计划管理工具之外,所有工具保守要求人工批准;模型无法自行跳过审批 | -| 暂停与引导 | 模型生成中、工具在途及等待人工时均可请求暂停;到安全边界挂起,提交意见后保留已完成事实并放弃旧代码未执行后缀 | -| Planning | 计划、当前步骤和版本持久化;恢复后不自动把拒绝的动作对应步骤标为完成;新意见交给模型重评计划 | -| 幂等和恢复 | 相同决定重复提交返回同一回执;成功调用回放结果;不确定副作用禁止自动重试 | -| 刷新与断线 | 待办从 PostgreSQL 重载,SSE 重新订阅不触发新运行;浏览器离开不取消 producer | -| 原功能兼容 | 普通聊天默认原路径;NL2Agent、自动化提案、MCP 权限和旧卡片保留原业务实现 | - -首版只开放 JSON 值和线性工具调用。附件、托管子 Agent、A2A、任意 Python、并行执行和 sandbox continuation 不支持;不得用审批模式替代这些执行器的专门安全适配。工具自身内部仍可能访问网络或调用其他系统,审批针对该工具的一次注册调用,不构成对工具内部实现的沙箱隔离。 - -主动提问受模型行为影响;运行时提供真实工具和明确上下文,不保证任何模型在所有模糊问题上都主动调用。 - -## 2. 代码分析与依赖方向 - -原 `resume=true` 主要恢复 StreamingChannel / Redis 中的输出,没有保存 Python 执行位置。原 `stop_event` 是协作停止信号,不能承载人工等待。Planning 原 PlanRepo、质量验证器和业务确认卡片各有职责,不能直接充当通用审批边界。 - -本次采用独立协议与适配层: - -```mermaid -flowchart LR - UI[HumanInteractionPanel] --> API[HTTP boundary] - API --> Service[HumanInteractionService] - Service --> PG[(PostgreSQL)] - Scheduler[Existing LeaseScheduler] --> App[Application composition] - App --> Core[CoreAgent] - Core --> Runtime[HumanInteractionRuntime] - Runtime --> Executor[LinearToolExecutor] - Runtime --> Port[InteractionPort] - Port --> Adapter[RuntimeInteractionPort] - Adapter --> Service -``` - -| 模块 | 职责 | -| --- | --- | -| `sdk/nexent/core/human_interaction/contracts.py` | 挂起、终止、待核对控制类型与宿主端口;SDK 不读取数据库和环境变量 | -| `executor.py` / `codec.py` | 整段执行前 AST 检查、稳定调用槽位、显式 JSON 检查点编解码;不使用 eval、exec 或 pickle | -| `runtime.py` | Agent 检查点、工具调用、计划适配和 steering;不依赖 FastAPI 或业务卡片 | -| `backend/services/human_interaction/service.py` | owner scope、请求生命周期、决定 CAS、幂等、到期和控制 | -| `runtime_port.py` | 冻结参数、调用账本、租约 fencing、执行前重新授权和事件持久化 | -| `application.py` | 复用现有 Agent 准备/流式输出与 LeaseScheduler;统一组装依赖 | -| `backend/apps/human_interaction_app.py` | HTTP 身份解析、输入校验、异常映射 | -| `frontend/features/humanInteraction/` | 独立 API client 与问题、审批、暂停、终止面板 | - -CoreAgent 仅增加可选生命周期接入点。未启用 HITL 时保持原 executor、工具 guardrail、隐式计划收尾及旧流式行为。新控制类型继承 BaseException,避免被既有 `except Exception` 工具包装器转换成普通字符串后继续运行。 - -## 3. 持久化与安全语义 +有效表单经过现有内容过滤,产生 `human_interaction` 消息单元和包含全部问题、选项的普通 `final_answer`,通过 `RuntimeFinalAnswer` 结束本轮。没有等待人工的运行实例,不调用 Python executor、最终质量重试或额外模型总结。Planning 的未完成步骤不因反问自动完成。托管子 Agent、NL2Agent、NL2Skill 保持各自原协议。 -新增四张表:`human_run_t`、`human_request_t`、`human_execution_t`、`human_event_t`,建表脚本为 `deploy/sql/migrations/v2.6.0_merged_migrations.sql`,未修改已合入 develop 的 SQL。 +Web 直接复用原 `ClarificationCard` 的外框、图标、文本/单选/多选/其他输入和 footer 样式。问题正在流出时可填写,流完成后才允许提交。提交把问题和可读答案组合成同一会话的普通新 query。请求被拒绝时保留卡片输入;普通 composer 同样恢复草稿和附件,且不自动重试。切换会话后的回调绑定原会话。较早历史、失败或停止的卡片只读;有效卡片隐藏精确匹配的问题文本回退,不支持卡片的客户端仍获得完整文本。 +停止仍调用既有 stop 接口并触发协作取消。stop 成功只代表已受理,实际 worker 退出之前继续保留运行占位。下一次发送若遇到 `X-Stream-Status: conflict`,前端显示“上一轮正在结束,请稍后再发送”并保留输入。停止后不再进入新 step、质量重试或 max-steps 总结。 -### 数据库字段与写入约束 +## 普通历史与持久化 -模型统一定义在 `backend/database/db_models.py`,继承 `TableBase`。原 `human_interaction_models.py` 保留导入兼容。 +卡片使用既有 `conversation_message_unit_t` 的 `unit_type=human_interaction`,表单 JSON 带 `schema_version: 1`。SSE 的 `content` 直接输出 JSON 对象,仅在入库时序列化为文本;历史回放兼容旧 `clarification` 单元。普通 completed assistant 正文保留问题回退;消息终态和输出单元沿用原批量保存事务,没有新增表或字段。 -| 表 | 单列技术主键 | 业务标识与关系 | -| --- | --- | --- | -| `human_run_t` | `run_record_id INT4`,自增 | `run_id VARCHAR(36)` 保留公开 UUID;`conversation_id INT4` 逻辑关联会话 | -| `human_request_t` | `request_record_id INT4`,自增 | `run_record_id INT4` 关联 run;`request_id VARCHAR(36)` 保留公开 UUID | -| `human_execution_t` | `execution_id INT4`,自增 | `run_record_id INT4` 与 `slot VARCHAR(100)` 标识调用位置 | -| `human_event_t` | `event_id INT4`,自增 | `run_record_id INT4` 与 `seq BIGINT` 标识 SSE 重放位置 | +下一轮历史允许 completed 和 stopped,保留 owner、tenant、软删除和当前消息边界限制。stopped assistant 带固定说明“上一轮已由用户停止,以下内容为已保存的部分结果。”,优先使用已保存正文;无正文时仅投影已完成 execution_logs 与成果引用,限制数量及字符数。工具开始、parse 和未完成观察不解释为成功结果。没有有效回复的旧 user 仍保留任务及“该轮未生成有效回复”的说明。历史摘要 covered-through 规则未扩展为执行检查点。 -- 四张表包含 `created_by`、`create_time`、`updated_by`、`update_time`、`delete_flag` 五个审计字段,所有列均有数据库英文注释。审计时间按 UTC 存为 `TIMESTAMP`;租约和请求过期时间保留 `TIMESTAMPTZ`。用户操作记录用户,调度器/租约维护记录 `system:hitl`。事务中的每次写入维护审计信息;触发器同时保证原始 SQL 更新刷新时间并保留创建信息,原始 SQL 调用者须显式提供操作人。 -- 状态/种类为 `VARCHAR(30)`,service 层统一验证状态集合、非负整数范围、标记和字符串长度。工具名称及 worker 标识上限为 200,slot/idempotency key 上限为 100。技术主键容量为正 INT4;`event_seq/seq` 为高频流式事件计数而非技术主键,继续使用 BIGINT,避免改变既有 SSE 游标范围。 -- 不声明外键、业务唯一约束或唯一索引。创建 run 时在同一事务持有公开 UUID 和 `(tenant_id, user_id, conversation_id)` 的 advisory lock,service 检查公开 ID 不重用、会话归属及唯一活动 run。后续写入持有父 run 行锁,验证整数引用、一条活动待办、每个调用 slot 一条活动回执及不重复的事件序号。审批版本和幂等摘要继续由 service 检查。所有正常查询过滤 `delete_flag='N'`;通过 run 事务软删父记录时,同一事务软删请求、执行回执和事件并更新审计。 -- 仅 `human_event_t.payload` 使用 JSONB:必须是 `{chunk_cipher: string}` 或 `{type: string, content: object}`,拒绝非法 JSON 值。输出块先加密。其余 payload/checkpoint/plan/arguments/result 是服务或 SDK 所有的可变 JSON 快照,Fernet 密文以 TEXT 保存;可空字段表示尚未生成,不给可变模型输出设任意固定长度。决定文本沿用 8000 字符等原有命令校验。 -- 非唯一索引分别支撑公开 run 查询、owner 会话历史、调度领取、run 请求查询、调用 slot 回放及 SSE 顺序分页;活动行索引统一包含软删过滤条件。 -- 建表脚本面向新功能的全新数据库,直接创建四张表、非唯一索引、字段注释和审计触发器。 +## 代码位置 -- PostgreSQL 是事实来源,READY run 本身就是可重新领取的持久执行队列;等待人工时结束 worker attempt 并释放租约,保留逻辑运行占位。 -- run 行锁串行化决定、暂停、终止和工具派发。请求版本、动作摘要与幂等键由服务端核验,客户端不能指定身份或执行状态。 -- 工具参数在内容 guardrail 后冻结;动作 HMAC 绑定 run、owner、调用槽位、工具、参数、配置摘要和策略版本。恢复时检查工具配置和注册类实现摘要。 -- 每个调用以 `step_number:statement_index` 为稳定槽位,独立于参数摘要。SUCCEEDED / REJECTED 回放结果;STARTED / UNKNOWN 转为 RECOVERY_REQUIRED,禁止猜测是否成功。 -- 工具实际返回后先保存执行回执,再做结果质量检查。结果校验失败、执行后表达式错误或聊天记录落库失败,均不能引发盲目重试副作用。 -- 最终输出写入检查点;完成检查点后进程崩溃,不重新调用模型来生成已完成的答案。 -- 新 worker 领取时递增 fence。旧 worker 失去租约后无权获取新动作或写入回执。工具内部已经开始的外部操作不能被数据库 fence 撤销。 -- 参数、答案、上下文、计划、检查点和输出块以 Fernet 加密存储;审批投影对常见凭据字段递归脱敏。任意自然语言中的秘密无法仅依靠字段名保证识别,用户不得在回答中输入密钥。 -- SSE 按 run 内事件序号重放;输出块批量写入事务(最多 32 块,持续产出时约 250ms 刷新),收尾强制刷出。人类控制状态不依赖 token 输出到达顺序。 -- 收到终止请求后不再派发尚未开始的动作;在途工具可能继续执行并写入回执。终止不是外部操作回滚。 - -RECOVERY_REQUIRED 必须先检查下游事实。当前没有“强制成功”或“忽略不确定状态继续”接口,也不提供自动对账;可以终止逻辑任务后由操作人员依据下游证据处理,不应直接重新发送原指令。 - -## 4. HTTP 与页面使用 - -在现代聊天 `/newchat` 输入框上方开启“人在回路(受控工具执行)”,再发送普通聊天。服务未启用时面板不显示。运行中可暂停并补充意见、批准/拒绝一次动作或终止任务;断开输出后可重新连接。 - -| 接口 | 行为 | +| 位置 | 职责 | | --- | --- | -| `POST /agent/run`,`enable_hitl: true` | 新建受控逻辑运行,返回 SSE 和 run_id | -| `POST /agent/run`,`hitl_run_id`、`hitl_after_event` | 订阅现有运行输出,不创建新运行 | -| `GET /agent/human-interactions/capabilities` | 查询开关与首版执行范围 | -| `GET /agent/human-interactions/conversation/{id}` | 获取当前 owner 在会话中的最近运行及待办 | -| `GET /agent/human-interactions/{run_id}` | owner scope 快照 | -| `POST /agent/human-interactions/{run_id}/requests/{request_id}/decisions` | 提交版本、digest、idempotency_key、decision 和可选 text | -| `POST /agent/human-interactions/{run_id}/pause` | 请求在安全边界暂停;等待人工时转换为意见输入 | -| `POST /agent/human-interactions/{run_id}/terminate` | 终止逻辑运行、撤销待处理请求 | - -决策命令仅接受 `answer`、`approve`、`reject`、`steer`,允许的组合由请求类型决定。跨 owner / tenant 返回 404;过期返回 410;版本或状态冲突返回 409;未知字段和非法答案返回 422。批准时不接受修改参数;需要修改应拒绝并给出意见,或暂停后提交新约束。 - -## 5. 启用与回退 +| `sdk/nexent/core/agents/clarification.py` | 纯表单、提示词、名称选择及问题文本 | +| `sdk/nexent/core/agents/output_protocol.py` | 完整 AST 字面量识别与拒绝非法动作 | +| `sdk/nexent/core/agents/core_agent.py` | 过滤、普通终结和停止边界 | +| `sdk/nexent/core/agents/nexent_agent.py` | 根 Agent 能力与生产系统上下文注入 | +| `sdk/nexent/core/concurrency/cancellation.py` | 普通 `RunTerminated(BaseException)` | +| `backend/management/services/agent/run.py` | 普通 producer、保存与 worker 退出后注销 | +| `backend/database/conversation_db.py` | 停止轮次和未配对任务历史投影 | +| `frontend/lib/clarification.ts` | 表单校验、映射、可读 query 和回退去重 | +| `frontend/app/[locale]/newchat/components/clarification-message-card.tsx` | 普通消息卡片及普通发送接线 | +| `frontend/app/[locale]/newchat/utils/ordinary-send.ts` | 被拒绝发送的乐观消息、草稿和附件恢复 | -配置样例:`deploy/env/hitl.env.example`。不要直接覆盖现有 `.env`。 +## 接口与部署 -1. 初始化数据库时执行新增建表脚本。当前工作仅在隔离测试库执行了此脚本。 -2. 为全部 runtime 实例配置同一持久 Fernet 密钥 `HITL_ENCRYPTION_KEY`,放在部署 secret 中,不提交 Git、不打印到日志。密钥丢失后现有运行无法恢复;首版不支持在线轮换。 -3. 设置 `HITL_ENABLED=true`,`HITL_ACCEPT_NEW_RUNS=true`。`HITL_WAIT_SECONDS` 默认 86400,`HITL_MAX_CONCURRENCY` 默认 2。没有有效密钥时启动失败关闭。 -4. 重建并更新包含 SDK、后端、建表 SQL 和前端的发布产物,先用无副作用工具灰度检查,再接入需要批准的动作。 +保留普通 `POST /agent/run`、`GET /agent/stop/{run_id}` 和 northbound 对应普通能力。携带 `enable_hitl`、`hitl_run_id`、`hitl_after_event` 的旧执行请求明确返回参数错误,包括 false、null 和零值;旧专属路由不再注册。 -如需本地生成密钥文件,可在项目根目录运行以下命令;命令只写文件,不输出密钥,文件已存在时拒绝覆盖: +新增 `deploy/sql/migrations/v2.6.1_001_remove_human_interaction.sql`,依次删除 `human_event_t`、`human_execution_t`、`human_request_t`、`human_run_t` 和仅供四表使用的审计函数,不使用 CASCADE。已合入的初始化及历史 SQL 保持原样。新安装先执行历史 SQL,再执行清理迁移,最终不存在四表。 -```bash -backend/.venv/bin/python - <<'PY' -import os -from cryptography.fernet import Fernet -fd = os.open('/tmp/nexent-hitl-key', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) -with os.fdopen(fd, 'wb') as output: - output.write(Fernet.generate_key()) -PY -``` - -回退时先设置 `HITL_ACCEPT_NEW_RUNS=false` 并滚动重启:停止接收新 HITL 运行,但保持 `HITL_ENABLED=true`,让既有请求、恢复队列和终止接口继续可用。确认既有运行全部完成/终止后,再关闭总开关。切勿删除迁移或密钥,也不要把关闭审批理解为待批动作获得批准。 +部署时须先停止旧进程和旧 scheduler,再同步更新 Web/runtime 并执行清理迁移。清理直接删除旧 HITL 数据,不提供搬迁或执行恢复。本次开发验证只对隔离临时 PostgreSQL 容器执行迁移,没有部署或修改现有业务数据库。 -## 6. 验证证据 +## 验证 -### PR #3927 SQL 检视修订验证(2026-09-15) +使用 Python 3.11 的 backend 环境,单独运行既有测试文件以避免旧测试模块级 mock 相互污染。新增测试覆盖 AST 非执行边界、结构化输出、真实 CoreAgent 终结、真实 worker 槽位释放、内容过滤、停止与 Planning、普通批量消息保存、隔离 SQL 历史查询及旧字段拒绝。普通运行、附件/子 Agent 上下文、停止、northbound 和代理回归也已运行。 -在独立 PostgreSQL 15 临时容器中运行 **212 项定向测试,全部通过**:数据库规范/建表回归 30 项,既有 PostgreSQL + CoreAgent 集成 68 项,HTTP/流式/SDK 回归 114 项。没有调用真实外部模型或修改业务数据库。 - -- 每项数据库测试均从空 schema 执行一次建表脚本,验证创建后的模型映射及完整 HITL 流程。 -- 验证单列 INT4 主键、整数逻辑关联、五个审计字段、全部字段注释及 JSONB 类型;没有外键、业务唯一约束或数据库状态 CHECK。 -- 验证并发创建只产生一个活动 run、并发请求只保留一条待办、并发事件没有重复/缺号,以及原有审批幂等、跨租户隔离和执行回执保护。 -- 验证创建/审批/调度/租约/原始 SQL 的审计维护、非 UTC 数据库会话、父子软删、字段边界和 BIGINT SSE 游标兼容。 -- 以 5000 条 run、50000 条 event 执行 `EXPLAIN (ANALYZE, BUFFERS)`:历史查询、调度领取和事件重放均使用对应非唯一索引。该检查不等于生产容量压测。 -- 数据库回归测试文件的 Ruff 检查及 `git diff --check` 通过;Ruff 导入检查按项目模块作为 first-party 配置执行。 - -```bash -HITL_TEST_DATABASE_FILE=/absolute/path/to/isolated-test.dsn \ - backend/.venv/bin/python -m pytest -q \ - test/backend/database/test_human_interaction_persistence.py \ - test/backend/services/test_human_interaction.py \ - test/backend/app/test_human_interaction_app.py \ - test/backend/app/test_northbound_human_interaction_app.py \ - test/backend/services/test_human_interaction_stream.py \ - test/sdk/core/agents/test_human_interaction_runtime.py \ - test/sdk/core/agents/test_run_agent_hitl.py \ - test/sdk/core/agents/test_clarification_form.py -``` +隔离 PostgreSQL 15 已验证:初始化加全部版本迁移、旧四表升级清理、清理重复执行、专属审计函数消失及普通消息保留。临时容器验证后自动删除。 -### 功能初版的历史验证记录 +验证结果如下,均为本次实际运行结果: -以下 **1304 项通过** 为初版记录,本次 SQL 修订未全量重跑此清单: +| 检查 | 结果 | +| --- | --- | +| SDK / 后端 Python 测试 | 1958 项通过;673 项 SDK、1285 项后端 | +| 前端纯逻辑测试 | 17 项通过 | +| Playwright Chrome 普通聊天测试 | 7 项通过 | +| TypeScript 类型检查 | 通过 | +| Next.js 生产构建 | 通过;另行运行类型和 lint 检查,因为仓库构建配置跳过它们 | +| 新增前端文件 lint | 无错误或警告 | +| 已修改前端文件 lint 与 HEAD 对比 | 无新增错误;36 个既有错误仍存在 | +| 本次前端文件格式检查 | 18 个文件通过 | +| Python 语法、部署脚本 bash 语法、git diff 空白检查 | 通过 | +| PostgreSQL 15 新装、升级、重复清理和普通消息保留 | 通过 | -| 测试范围 | 通过数 | -| --- | ---: | -| 新增真实 PostgreSQL + CoreAgent 集成 | 25 | -| Agent 服务 / Agent API | 458 / 115 | -| CoreAgent / Planning / run_agent | 154 / 26 / 31 | -| NL2Agent | 43 | -| 自动化 facade / runner / scheduler / adapter | 37 / 15 / 9 / 17 | -| StreamingChannel / runtime state / run manager | 39 / 21 / 23 | -| observer / parallel executor / PlanRepo | 105 / 17 / 21 | -| 通用 LeaseScheduler / conversation DB | 13 / 135 | +七项浏览器用例覆盖文本/单选/多选/其他答案、必填禁用、冲突后保留答案、普通新 query 的可读内容、历史只读、中文窄屏,以及 composer 按钮和回车冲突恢复;还覆盖流式问题可填写但不能提前提交、完成后启用提交、停止后卡片只读、停止时保留草稿及分享页只读。已检查中英文卡片截图,窄屏 footer 允许换行,避免提示被按钮挤成逐字排列。 -新集成测试使用专用 PostgreSQL `hitl_test` 数据库,计数型下游工具和真实 SDK executor,包含 ReAct/Planning 多次 worker 重建、审批前零执行、同 key 并发决定、拒绝/过期/撤权、模型与工具阶段暂停、租约失效、参数/工具实现漂移、完成收尾崩溃、已排队决定与失败收尾竞争、API 伪造字段和跨租户访问。API 身份及生产模型准备过程部分使用替身;不代表完整外部模型/MCP/业务实例验收。 +前端测试 mock HTTP 服务和可控 SSE,SDK 使用可控模型/工具,并实际执行 CoreAgent 循环和 ThreadManager worker。没有调用外部真实模型,也没有部署当前业务实例。全量 `npm run lint` 仍受仓库既有格式和 any 类型问题阻碍,本次未扩展修改无关代码。 -测试会重建指定库的 `nexent` schema,仅允许数据库名为 `hitl_test`,绝不能使用业务数据库: +复验入口: ```bash -HITL_TEST_DATABASE_FILE=/absolute/path/to/isolated-test.dsn \ - backend/.venv/bin/python -m pytest -q test/backend/services/test_human_interaction.py +# Run Python files separately because existing tests install module-level mocks. +backend/.venv/bin/python -m pytest test/sdk/core/agents/test_clarification_protocol.py test/sdk/core/agents/test_clarification_form.py test/sdk/core/agents/test_clarification_runtime.py -q +backend/.venv/bin/python -m pytest test/backend/database/test_conversation_history_stopped.py test/backend/app/test_agent_legacy_fields.py -q ``` -旧 SDK 测试会改写 `sys.modules`,相关文件需分别运行,避免历史 mock 隔离方式互相干扰。 - -前端新增组件完成独立打包和针对性 ESLint 检查。全项目 TypeScript 检查仍受已有 `.next/types` 引用不存在页面和本地未安装 `@assistant-ui/react-lexical` 阻塞;未宣称完整前端构建通过。浏览器安全策略阻止本地预览访问,尚未完成真实页面点击、刷新和多标签 E2E。 - -## 7. 后续范围 - -以下内容仍属于后续阶段,不能从本次测试推断已经支持: - -- NL2Agent NEXT_TURN、资源安装/绑定和自动化提案迁入统一交互协议;本次仅回归其原有实现。 -- 后台自动化的人工 WAIT、任务 occurrence / 游标结算和完整 HA 演练。 -- 子 Agent / 并行分支、MCP Elicitation、A2A 和 sandbox 的 durable continuation。 -- 外置 artifact codec、外部工具可靠幂等对账、审计查询和指标管理页面。 -- 业务确认类独立 continuation registry、多审批人和委托授权。 -- 跨 attempt 合并到同一条原生 assistant message:当前保持同一逻辑 run,历史消息仍按各 attempt 落库。 -- 密钥轮换、容量与性能压测、真实模型和浏览器端到端验收。远端工具内部实现变更未必能被本地注册摘要发现。 +在 frontend 目录中可执行 `node --experimental-strip-types --test tests/humanClarification.test.ts tests/ordinarySend.test.ts tests/reasoningAccumulator.test.ts`、`npm run type-check` 和 `npm run build`。浏览器测试需先以 `pnpm exec next dev --hostname 127.0.0.1 --port 3100` 启动页面,再执行 `pnpm exec playwright test e2e/clarification-message.spec.ts --workers=1`;测试使用本机 Chrome,并拦截业务 API,避免写入实际服务。 diff --git a/doc/docs/zh/integration/integration-out/northbound-api.md b/doc/docs/zh/integration/integration-out/northbound-api.md index 29bd83c13d..b8053dc656 100644 --- a/doc/docs/zh/integration/integration-out/northbound-api.md +++ b/doc/docs/zh/integration/integration-out/northbound-api.md @@ -91,7 +91,6 @@ POST /nb/v1/chat/run |------|------|------|------| | `agent_name` | string | 是 | 目标 Agent 名称 | | `query` | string | 是 | 用户输入内容 | -| `enable_hitl` | boolean | 否 | 是否启用人在回路,默认 `false` | | `conversation_id` | integer | 否 | 已有对话 ID,不填则创建新对话 | | `attachments` | array | 否 | 附件列表(S3 URL 或附件元数据对象) | | `model_id` | integer | 否 | 模型 ID(覆盖 Agent 默认模型) | @@ -205,118 +204,40 @@ data: {"type":"model_output_thinking","content":",请稍候...","unit_index":1 data: {"type":"final_answer","content":"分析完成","unit_index":2} ``` -## 人在回路(HITL) +## 结构化反问与停止后补充 -对话入口为 `POST /api/nb/v1/chat/run`。本文其他路径省略网关公共前缀 `/api`;直连服务时请按实际部署的根路径访问。所有 HITL 接口沿用 `Authorization: Bearer {access_key}`,用户和租户由 API Key 解析,不接受调用方指定 `user_id` 或 `tenant_id`。 +Agent 在缺少关键信息时可返回结构化反问。本轮通过普通完成流程结束并释放 worker,客户端在收到流结束后,把答案作为同一 conversation 的普通新 query 发送。不需要开关、待办查询、决定提交或执行恢复接口。 -### 开启与运行过程 +### 反问 SSE -```bash -curl -N 'https://your-nexent-domain.com/api/nb/v1/chat/run' \ - -H "Authorization: Bearer ${NEXENT_API_KEY}" \ - -H 'Content-Type: application/json' \ - -d '{"agent_name":"general-assistant","query":"帮我准备通知","enable_hitl":true}' -``` - -`enable_hitl` 默认为 `false`。客户端应先查询 `GET /nb/v1/chat/human-interactions/capabilities`,读取响应的 `data.enabled`、`data.accept_new_runs` 和 `data.tool_approval_enabled`。runtime 关闭 HITL 或停止接收新 HITL 运行时,启动请求沿用普通对话行为;`enable_hitl=true` 不保证一定生成卡片,Agent 只在需要补充关键信息时提问。 - -启动后,runtime 为当前会话建立一个 `run_id`。遇到人工交互时持久化卡片并进入 `WAITING_HUMAN`;提交有效决定后进入 `READY`,由当前执行线程或调度器继续同一次运行。北向服务通过内部 JWT 代理到 runtime,复用其持久化、租户及用户归属校验、版本校验和幂等处理。 - -### 流式事件协议 - -继续使用 `data: {"type": ..., "content": ...}`。**卡片的 `type` 固定为 `human_interaction`**,卡片内容为 JSON 对象,不需要再次 `JSON.parse(content)`。 - -| `type` | `content` | 用途 | -|---|---|---| -| `conversation_created` | 对象,包含 `conversation_id` | 新建会话时的北向通知 | -| `human_run` | 对象,包含 `run_id`、`status`;快照还包含 `conversation_id`、`event_seq`、`requests` | 运行状态和待处理卡片快照 | -| `human_interaction` | 卡片对象 | 展示人工输入或确认卡片 | -| `human_decision` | 对象,包含 `run_id`、`request_id`、`status` | 决定已接受,更新卡片状态 | -| `human_execution` | 对象,包含工具执行槽位及状态 | 工具执行进度通知 | -| `model_output_thinking`、`final_answer`、`token_count` 等 | 沿用原运行时格式 | 普通 Agent 输出,北向原样透传 | - -`human_interaction.content.kind` 进一步区分: - -| `kind` | 提交的 `decision` | 输入 | -|---|---|---| -| `CLARIFICATION` | `answer` | 结构化卡片用 `answers`;兼容旧单问题卡片时用 `text` | -| `ACTION_APPROVAL` | `approve` / `reject` | 审批原卡片中的动作;不能修改工具参数后沿用原审批 | -| `USER_STEERING` | `steer` | 使用 `text` 提交暂停后的指导意见 | - -卡片示例(`digest` 仅为示例,提交时必须原样复制实际卡片的值): +外层继续使用 `data: {"type": ..., "content": ...}`。`type="human_interaction"` 的 `content` 是 **JSON 对象**,直接包含 `schema_version: 1` 和 `questions`;紧随的普通 `final_answer` 包含完整可读问题回退。 ```text -id: 12 -data: {"type":"human_interaction","content":{"request_id":"01a09fce-3a28-7860-8646-b0eec69d5f47","run_id":"01a09fce-3a28-7860-8646-b0eec69d5f46","kind":"CLARIFICATION","status":"PENDING","version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","expires_at":"2026-09-15T10:00:00+00:00","payload":{"schema_version":2,"questions":[{"id":"audience","type":"single_choice","title":"通知发给谁?","required":true,"options":[{"id":"team","label":"内部团队"},{"id":"client","label":"客户"}],"allow_other":true}]}}} -``` - -`payload.questions` 支持 `text`、`single_choice`、`multiple_choice`,最多 5 个问题,同一次运行最多 1 张澄清卡片。单选提交选项 ID 字符串,多选提交选项 ID 数组;允许其他答案时可加 `other_text`。必答项、未知选项及重复问题答案均由 runtime 校验。不要把卡片的内部题型 `type` 和外层流事件 `type` 混淆。 +data: {"type":"human_interaction","content":{"schema_version":1,"questions":[{"id":"audience","type":"single_choice","title":"通知发给谁?","required":true,"options":[{"id":"team","label":"内部团队"},{"id":"client","label":"客户"}],"allow_other":true,"placeholder":""}]},"unit_index":1} -### 查询与提交接口 - -以下表格中的公共前缀为 `/nb/v1/chat/human-interactions`: +data: {"type":"final_answer","content":"1. 通知发给谁?\n - 内部团队\n - 客户\n - 其他 / Other","unit_index":2} +``` -| 方法 | 路径 | 说明 | -|---|---|---| -| GET | `/capabilities` | 查询 runtime 的实际能力开关 | -| GET | `/conversation/{conversation_id}` | 当前 API 用户在此会话的最新 HITL 运行;没有可见运行时 `data=null` | -| GET | `/{run_id}` | 查询运行状态及 `requests` 中的待处理卡片 | -| POST | `/{run_id}/requests/{request_id}/decisions` | 提交卡片答案、批准、拒绝或暂停后的意见 | -| GET | `/{run_id}/events?after_event=12` | 订阅同一运行的后续 SSE 事件 | -| POST | `/{run_id}/pause` | 请求在安全执行边界暂停;不代表立即中断正在执行的工具 | -| POST | `/{run_id}/steer` | 在当前运行追加指导意见;会取消当前待处理卡片 | -| POST | `/{run_id}/terminate` | 终止当前运行并取消待处理卡片 | +`questions` 最多 5 项,支持 `text`、`single_choice`、`multiple_choice`。问题字段包括 `id`、`type`、`title`、`required`、`options`、`allow_other` 和 `placeholder`;选项使用 `id` 与 `label`。文本题没有选项;选择题提供 2–12 个选项。客户端可复用表单展示,在确认本轮流结束之前禁用提交。有效卡片已显示时可隐藏精确匹配的问题文本,纯文本客户端直接显示 `final_answer`。 -JSON 成功响应统一为 `{"message":"success","requestId":"...","data":...}`。决定接口的 `data` 为 `{"run_id":"...","request_id":"...","accepted":true}`。SSE 接口仍直接返回事件流。 +### 回答作为下一 query -填写卡片后,**调用决定接口,不要将答案作为新的 `query` 再调用 `/chat/run`**: +将问题和可读答案组合成普通文本,调用现有接口,并使用该会话的真实 `conversation_id`: ```bash -curl -X POST \ - 'https://your-nexent-domain.com/api/nb/v1/chat/human-interactions/01a09fce-3a28-7860-8646-b0eec69d5f46/requests/01a09fce-3a28-7860-8646-b0eec69d5f47/decisions' \ +curl -N 'https://your-nexent-domain.com/api/nb/v1/chat/run' \ -H "Authorization: Bearer ${NEXENT_API_KEY}" \ -H 'Content-Type: application/json' \ - -d '{ - "version":1, - "digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "idempotency_key":"answer-20260914-0001", - "decision":"answer", - "answers":[{"question_id":"audience","value":"team"}] - }' + -d '{"conversation_id":123,"agent_name":"general-assistant","query":"补充上一轮问题:通知发给内部团队,其他补充:仅研发部门。"}' ``` -- `version`、`digest` 从原卡片复制;它们将决定绑定到具体版本和动作。 -- `idempotency_key` 放在 JSON 请求体中,长度 8–100。同一提交超时重试时复用同一键和完全相同的内容;相同键对应不同内容返回 `409`。 -- 结构化澄清只传 `answers`,不要同时传 `text`;旧单问题澄清或 `USER_STEERING` 使用 `text`。 -- 审批使用同一接口,将 `decision` 改为 `approve` 或 `reject`,不传 `answers`。 -- 没有待处理卡片时需要补充新意见,可调用 `/{run_id}/steer`,请求体为 `{"message_id":"guidance-0001","text":"请按新的要求继续"}`。`message_id` 是该条指导意见的幂等标识。此操作会取消待处理卡片,并不表示批准待审工具动作。 - -只凭 `conversation_id` 无法明确区分同一会话的多轮运行、不同卡片和卡片版本,因此提交接口必须绑定 `run_id + request_id + version + digest`。会话查询接口用于找回这些信息。 - -### 等待、断线和恢复 - -1. 消费启动流,保存 `conversation_id`、`run_id` 和已经完整消费的 SSE `id`。`human_run.requests` 也可能包含待处理卡片,按 `request_id + version` 合并,避免重复展示。 -2. 收到 `human_interaction` 后展示卡片,收到 `human_decision` 后关闭或更新卡片。 -3. 决定接口返回 `accepted=true` 只表示输入已接受,执行结果仍从 SSE 读取。原流还连接时可继续消费;原流已结束或断开时,订阅 `GET /nb/v1/chat/human-interactions/{run_id}/events?after_event=<最后消费的ID>`。 -4. 未提供 `after_event` 时,订阅接口也接受 `Last-Event-ID` 请求头;显式 `after_event` 优先。默认 `0` 重放全部持久化事件。订阅不写入用户消息、不重新启动 Agent。 -5. 每次订阅先发送无 SSE `id` 的当前 `human_run` 快照,再按顺序重放大于游标的持久化事件。**不要把初始快照的 `event_seq` 当作已消费游标**,否则会跳过尚未读取的输出;无 `id` 的快照和心跳不推进客户端游标。提交答案后应继续使用提交前最后消费的 ID。 -6. `WAITING_HUMAN` 是等待输入,不是完成;此时 SSE 可能保持连接,也可能结束订阅。只有 `human_run.status` 为 `COMPLETED`、`FAILED`、`STOPPED`、`EXPIRED` 或 `RECOVERY_REQUIRED` 时,客户端才应结束本轮等待。`RECOVERY_REQUIRED` 表示执行无法自动恢复,不能将原动作盲目重发。 - -`GET /nb/v1/chat/stop/{conversation_id}` 同样会终止此会话当前 API 用户的活跃 HITL 运行。 +这里的 `123` 仅为示例。答案应包含选项文案以及用户填写的其他内容,而不只是问题 ID 或选项 ID。新运行读取普通会话历史,不恢复上一轮执行栈或计划游标。刷新后从普通消息中的 `human_interaction` 单元重建卡片;较早历史卡片只读,答案在后续 user 消息中查看。 -### 错误与部署要求 +### 普通停止与兼容性 -| HTTP 状态 | 含义 | -|---|---| -| 401 | API Key 或内部 runtime 身份验证失败 | -| 404 | 运行/卡片不存在,或不属于当前租户及用户 | -| 409 | 卡片已处理、版本/摘要不一致,或幂等键对应不同内容 | -| 410 | 本次提交检测到卡片过期;若过期状态已被调度器处理,也可能返回 `409` | -| 422 | 请求体、回答、决定类型或事件游标不合法 | -| 503 | runtime 未启用 HITL,无法查询运行或处理决定 | -| 502 / 504 | runtime 不可连接 / 请求超时 | +仍使用 `GET /nb/v1/chat/stop/{conversation_id}`。成功表示停止已受理,在途 worker 实际退出前继续占用运行位置。若下一次发送收到 `X-Stream-Status: conflict`,应保留用户输入并提示稍后再发送,不自动重试。用户停止后的新 query 可读取原始任务、停止说明和已保存的部分结果;停止不会回滚已发生的外部操作。 -部署需已应用 `deploy/sql/migrations/v2.6.0_merged_migrations.sql`,runtime 配置有效的 `HITL_ENCRYPTION_KEY`,并开启 `HITL_ENABLED`、`HITL_ACCEPT_NEW_RUNS`。工具审批另外受 `HITL_TOOL_APPROVAL_ENABLED` 控制;默认的 `native-live-v1` 保留原执行现场以继续运行,worker 丢失后不能保证自动恢复,会进入需要恢复处理的状态。北向接口不改变已有执行模式的能力边界。 +旧 HITL 专属路由、审批、暂停续跑、四表运行依赖和密钥配置已移除。北向 `/chat/run` 若收到 `enable_hitl`、`hitl_run_id` 或 `hitl_after_event`,明确返回 `400`;字段即使为 false、null 或零也不能继续发送。Web 与 runtime 应同步更新。普通身份、权限、附件、元数据及错误响应保持原有约定。 ## 📎 上传对话附件 diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 08e0552e0b..cb941eb13d 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -25,9 +25,12 @@ import { import { getConversationDateBoundaries } from "@/lib/conversationViewport"; import { toMessageCreatedAt } from "@/lib/messageDate"; import { buildHistoricalMessageTiming } from "@/lib/messageTiming"; +import { + appendClarificationPart, + isClarificationFallback, +} from "@/lib/clarification"; import { stripAnsiControlSequences } from "@/lib/ansi"; import { createReasoningAccumulator } from "@/lib/reasoningAccumulator"; -import { appendGuidanceMessage } from "@/features/humanInteraction/guidanceMessage"; import { storageService } from "@/services/storageService"; import { parseAutomationProposal } from "@/features/agentAutomation/parseProposal"; @@ -440,7 +443,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { (item.image_key as string | undefined) || (isImage ? derivedImageKey : undefined), retrievalHighlightTerms: getRetrievalHighlightTerms( - item.score_details, + item.score_details ), }); } @@ -585,10 +588,6 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { }; for (const [partIndex, part] of messageParts.entries()) { - if (part.type === "user_steering") { - appendGuidanceMessage(content, part.content); - continue; - } // Note: do NOT early-return on `!part.content` at the top level — // `tool` items stored in the database have an empty `content` field // and only carry `tool_name` + `tool_arguments` (see the @@ -920,6 +919,20 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { continue; } + if ( + part.type === "human_interaction" || + part.type === "clarification" + ) { + flushReasoning(); + appendClarificationPart( + content, + part.content, + part.unit_index, + msg.status === "completed" + ); + continue; + } + if (part.type === "automation_proposal") { flushReasoning(); const proposal = parseAutomationProposal(part.content); @@ -952,7 +965,10 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { continue; } - if (part.type === "final_answer") { + if ( + part.type === "final_answer" && + !isClarificationFallback(content, part.content) + ) { flushReasoning(part.invocation_id); if (part.content) { const textPart: any = { type: "text", text: part.content }; @@ -1039,7 +1055,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { citeIndex, toolSign: item.tool_sign as string | undefined, retrievalHighlightTerms: getRetrievalHighlightTerms( - item.score_details, + item.score_details ), messageId, }); diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index ad973bad32..50ec01bfa6 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -10,13 +10,19 @@ import type { import { conversationService } from "@/services/conversationService"; import log from "@/lib/logger"; -import { humanInteractionClient } from "@/features/humanInteraction/client"; -import { appendGuidanceMessage } from "@/features/humanInteraction/guidanceMessage"; import { completeTrailingToolCalls } from "@/lib/toolCallStatus"; +import { + appendClarificationPart, + isClarificationFallback, + isHumanInteractionEvent, + parseClarification, + renderQuestionText, +} from "@/lib/clarification"; import { stripAnsiControlSequences } from "@/lib/ansi"; import { createReasoningAccumulator } from "@/lib/reasoningAccumulator"; import { parseAutomationProposal } from "@/features/agentAutomation/parseProposal"; import type { SkillParam, ToolParam } from "@/types/agentConfig"; +import type { HumanInteractionEvent } from "@/types/clarification"; // Backend SSE chunk format interface ImageMetadata { @@ -205,7 +211,9 @@ export interface Nl2aResourceCandidate { } export type Nl2aInstallationFormKind = - "SKILL_CONFIG" | "MCP_REMOTE" | "MCP_CONTAINER"; + | "SKILL_CONFIG" + | "MCP_REMOTE" + | "MCP_CONTAINER"; export interface Nl2aResourceInstallationOption { option_id: string; @@ -265,15 +273,13 @@ interface NexentRunConfig { threadId?: string; onServerConversationId?: (serverId: string, initialQuestion?: string) => void; onGenerationStopped?: (conversationId: number) => void; - onHumanInteractionEvent?: () => void; onRunId?: (runId: string) => void; + onRunStarted?: () => void; + onRunRejected?: (error: unknown, message?: ThreadMessage) => void; resume?: boolean; agentId?: number | string; agentVersionNo?: number; enablePlan?: boolean; - enableHitl?: boolean; - hitlRunId?: string; - hitlAfterEvent?: number; runtimeMode?: "nl2agent" | "nl2skill" | "agent-debug"; knowledgeScope?: import("@/types/knowledgeScope").ConversationKnowledgeScope; onKnowledgeScopeResolved?: ( @@ -526,6 +532,12 @@ function extractTextContent(messages: readonly ThreadMessage[]): string { .map((part) => { if (part.type === "text") return part.text ?? ""; if (part.type === "image") return "[image]"; + if (part.type === "data" && part.name === "clarification") { + const form = parseClarification( + (part.data as { form?: unknown })?.form + ); + if (form) return renderQuestionText(form); + } return ""; }) .join(""); @@ -638,7 +650,7 @@ function parseFileAttachments( * Parses one SSE line `data: {...}` into an SseChunk object. * Returns null for non-data lines or malformed JSON. */ -function parseSseChunk(line: string): SseChunk | null { +function parseSseChunk(line: string): SseChunk | HumanInteractionEvent | null { if (!line.startsWith("data: ")) return null; const jsonStr = line.slice(6).trim(); if (!jsonStr) return null; @@ -648,7 +660,7 @@ function parseSseChunk(line: string): SseChunk | null { if (typeof parsed.content === "string") { parsed.content = stripAnsiControlSequences(parsed.content); } - return parsed as unknown as SseChunk; + return parsed as unknown as SseChunk | HumanInteractionEvent; } if (typeof parsed.status === "string") { return { type: "status", content: parsed.status }; @@ -1403,8 +1415,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const serverThreadId = custom?.threadId; const onServerConversationId = custom?.onServerConversationId; const onRunId = custom?.onRunId; - const isResume = - !isEphemeralRuntime && (custom?.resume === true || !!custom?.hitlRunId); + const isResume = !isEphemeralRuntime && custom?.resume === true; const nl2AgentId = typeof custom?.agentId === "string" ? Number(custom.agentId) @@ -1463,7 +1474,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const history = historyMessages.map((msg) => { const customMetadata = isNl2Agent ? (msg.metadata?.custom as - { nl2agentCardAction?: Nl2AgentCardAction } | undefined) + | { nl2agentCardAction?: Nl2AgentCardAction } + | undefined) : undefined; const text = customMetadata?.nl2agentCardAction ? JSON.stringify(customMetadata.nl2agentCardAction) @@ -1539,17 +1551,13 @@ export const remoteChatModelAdapter: ChatModelAdapter = { ? numericServerThreadId : null; let backendRunId: string | null = null; - let humanRunId: string | null = custom?.hitlRunId ?? null; let backendStopPromise: Promise | null = null; let abortHandled = false; let userAborted = false; const stopBackendRun = async (runId: string | number) => { if (backendStopPromise) return; - backendStopPromise = ( - humanRunId - ? humanInteractionClient.control(humanRunId, "terminate") - : conversationService.stop(runId) - ) + backendStopPromise = conversationService + .stop(runId) .then(() => undefined) .catch((error) => { log.error( @@ -1563,7 +1571,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (abortHandled) return; abortHandled = true; const abortReason = abortSignal?.reason as - { detach?: boolean } | undefined; + | { detach?: boolean } + | undefined; if (abortReason?.detach) { log.log( `[ChatModelAdapter] Local stream detached from conversation ${backendConversationId ?? "unknown"}` @@ -1591,7 +1600,8 @@ export const remoteChatModelAdapter: ChatModelAdapter = { }; let agentResponse: - ReadableStreamDefaultReader | { type: "json"; data: unknown }; + | ReadableStreamDefaultReader + | { type: "json"; data: unknown }; let returnedRuntimeMetadataVersion: number | undefined; try { agentResponse = await conversationService.runAgent( @@ -1609,9 +1619,6 @@ export const remoteChatModelAdapter: ChatModelAdapter = { is_debug: isAgentDebug, is_resume: isResume, enable_plan: custom?.enablePlan === true, - enable_hitl: !isEphemeralRuntime && custom?.enableHitl === true, - hitl_run_id: !isEphemeralRuntime ? custom?.hitlRunId : undefined, - hitl_after_event: custom?.hitlAfterEvent, knowledge_scope: requestBody.knowledge_scope as | import("@/types/knowledgeScope").ConversationKnowledgeScope | undefined, @@ -1634,7 +1641,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const numericId = Number(conversationId); if (!Number.isNaN(numericId) && numericId > 0) { backendConversationId = numericId; - if (abortSignal?.aborted) { + if (userAborted) { custom?.onGenerationStopped?.(numericId); void stopBackendRun(numericId); } @@ -1652,15 +1659,17 @@ export const remoteChatModelAdapter: ChatModelAdapter = { (runId) => { backendRunId = runId; onRunId?.(runId); - if (abortSignal?.aborted) { + if (userAborted) { void stopBackendRun(runId); } } ); + if (!("type" in agentResponse)) custom?.onRunStarted?.(); if (custom?.runtimeMetadata !== undefined) { custom.onRuntimeMetadataSent?.(returnedRuntimeMetadataVersion); } } catch (error: unknown) { + custom?.onRunRejected?.(error, messages[lastUserIndex]); cleanupAbortHandler(); if ( error instanceof Error && @@ -1672,6 +1681,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { return; } log.error("[ChatModelAdapter] Agent request failed:", error); + if (custom?.onRunRejected) return; throw error; } @@ -1938,10 +1948,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { for (const [path, index] of nl2SkillFilePartIndices) { if (!checkpoint.files.has(path)) createdIndices.add(index); } - if ( - checkpoint.summary === null && - nl2SkillSummaryPartIndex !== null - ) { + if (checkpoint.summary === null && nl2SkillSummaryPartIndex !== null) { createdIndices.add(nl2SkillSummaryPartIndex); } for (const index of [...createdIndices].sort((a, b) => b - a)) { @@ -2160,7 +2167,15 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Generate a stable message ID for this stream so MarkdownText can look up sources const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const buildStreamResult = (content: any[]): ChatModelRunResult => ({ - content: collapseSubAgentParts(content), + content: collapseSubAgentParts( + content.map((part) => + part.type === "data" && + part.name === "clarification" && + (userAborted || content.some((item) => item.isError)) + ? { ...part, data: { ...part.data, completed: false } } + : part + ) + ), metadata: nl2a ? { custom: { nl2a } } : undefined, }); @@ -2168,12 +2183,11 @@ export const remoteChatModelAdapter: ChatModelAdapter = { let firstTokenTime: number | undefined; let toolCallCount = 0; let storedTiming: ReturnType | null = null; - let hitlTerminal: boolean | undefined = undefined; try { while (true) { const { done, value } = await reader.read(); - if (done || hitlTerminal) break; + if (done) break; buffer += decoder.decode(value, { stream: true }); @@ -2185,60 +2199,35 @@ export const remoteChatModelAdapter: ChatModelAdapter = { const chunk = parseSseChunk(line); if (!chunk) continue; - if (handleModelAttemptControl(chunk)) { - yield buildStreamResult(contentParts); - continue; - } - - if (chunk.type === "human_run") { - // The stream loop below has a finally but no catch: a malformed - // payload must not kill the whole chat stream. Skip the chunk and - // let the HITL controller snapshot/polling recover the state. - let value: Record; - try { - value = (typeof chunk.content === "string" - ? JSON.parse(chunk.content) - : chunk.content) as Record; - } catch (error) { - log.warn( - "[ChatModelAdapter] Failed to parse human_run chunk:", - error - ); - continue; - } - if (value && typeof value.run_id === "string") - humanRunId = value.run_id; - custom?.onHumanInteractionEvent?.(); - // Terminal HITL status: stop reading so isRunning flips false - // without waiting for the backend to close the stream. + if (isHumanInteractionEvent(chunk)) { + flushOpenReasoning(); if ( - value && - typeof value.status === "string" && - ["COMPLETED", "FAILED", "STOPPED", "EXPIRED"].includes(value.status) + appendClarificationPart( + contentParts, + chunk.content, + chunk.unit_index + ) ) { - hitlTerminal = true; - break; + yield buildStreamResult(contentParts); } continue; } + if (handleModelAttemptControl(chunk)) { + yield buildStreamResult(contentParts); + continue; + } if ( - ["human_interaction", "human_decision", "human_execution"].includes( - chunk.type - ) + chunk.type === "final_answer" && + isClarificationFallback(contentParts, chunk.content) ) { - custom?.onHumanInteractionEvent?.(); + flushOpenReasoning(); + completeVerificationPanel(); continue; } // Internal status / resume events: skip if (chunk.type === "status") continue; - if (chunk.type === "user_steering") { - if (appendGuidanceMessage(contentParts, chunk.content)) { - yield buildStreamResult(contentParts); - } - continue; - } if (chunk.type === "history_summary") { flushOpenReasoning(); if (updateHistorySummary(chunk.content)) { @@ -2301,7 +2290,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { } if (chunk.type === "step_count") { - // A new model step is a boundary; user guidance by itself is not. + // A new model step closes the previous reasoning part. flushOpenReasoning(chunk.invocation_id); // Fold `step_count` into the invocation's reasoning part text // so the rendering layer sees the same reasoning part shape @@ -2707,14 +2696,29 @@ export const remoteChatModelAdapter: ChatModelAdapter = { if (buffer.trim()) { const chunk = parseSseChunk(buffer); if (chunk && chunk.type !== "status") { - if (isNl2Skill) custom?.onNl2SkillEvent?.(chunk); - if (chunk.type === "step_count") { - flushOpenReasoning(chunk.invocation_id); + if (!isHumanInteractionEvent(chunk)) { + if (isNl2Skill) custom?.onNl2SkillEvent?.(chunk); + if (chunk.type === "step_count") { + flushOpenReasoning(chunk.invocation_id); + } } - if (chunk.type === "user_steering") { - if (appendGuidanceMessage(contentParts, chunk.content)) { + if (isHumanInteractionEvent(chunk)) { + flushOpenReasoning(); + if ( + appendClarificationPart( + contentParts, + chunk.content, + chunk.unit_index + ) + ) { yield buildStreamResult(contentParts); } + } else if ( + chunk.type === "final_answer" && + isClarificationFallback(contentParts, chunk.content) + ) { + flushOpenReasoning(); + completeVerificationPanel(); } else if (chunk.type === "history_summary") { flushOpenReasoning(); if (updateHistorySummary(chunk.content)) { diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index 381e253a7c..6d19790b70 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -62,11 +62,9 @@ import { combinedSkillDirectiveFormatter, skillDirectiveIconMap, } from "../ui/skill-directives"; +import { ordinarySendError, protectOrdinarySend } from "../utils/ordinary-send"; import { RuntimeMetadataEditor } from "@/components/chat/RuntimeMetadataEditor"; -import { useRunMessageQueueContext } from "@/features/humanInteraction/useRunMessageQueue"; -import { QueuedRunMessageStrip } from "@/features/humanInteraction/QueuedRunMessageStrip"; - export type ChatMode = "planning" | "execution"; export interface ComposerProps { @@ -221,15 +219,27 @@ export const Composer: FC = ({ disabled = false, }) => { const { t, i18n } = useTranslation(); - const zh = i18n.language.startsWith("zh"); const aui = useAui(); - const queue = useRunMessageQueueContext(); - const composerText = useAuiState((state) => state.composer.text); - const bufferedInput = Boolean(queue && (queue.active || queue.entry)); - const queueLocked = Boolean(queue?.entry || (queue?.active && queue.used)); - const enqueue = () => { - if (queue?.enqueue(aui.composer().getState().text)) - aui.composer().setText(""); + const runtime = aui.threads().__internal_getAssistantRuntime?.(); + const [sendError, setSendError] = useState(""); + const prepareSend = () => { + if (!runtime || runtime.thread.getState().isRunning) return; + const threadId = runtime.threads.getState().mainThreadId; + const thread = runtime.threads.getById(threadId); + const config = thread.composer.getState().runConfig; + setSendError(""); + const feedback = protectOrdinarySend(thread, { + restoreDraft: true, + onRejected: (error) => { + if (runtime.threads.getState().mainThreadId === threadId) { + setSendError(ordinarySendError(error, i18n.language)); + } + }, + }); + thread.composer.setRunConfig({ + ...config, + custom: { ...config.custom, ...feedback }, + }); }; const [knowledgeModalOpen, setKnowledgeModalOpen] = useState(false); const isRunning = useAuiState((state) => state.thread.isRunning); @@ -324,7 +334,6 @@ export const Composer: FC = ({ return (
-
= ({ { - if (bufferedInput || queueLocked) { + if (isRunning) event.preventDefault(); + else prepareSend(); + }} + onKeyDownCapture={(event) => { + if (isRunning && event.key === "Enter" && !event.shiftKey) event.preventDefault(); - enqueue(); - } }} > - {!compact && !bufferedInput && } - {bufferedInput || queueLocked ? ( -