From b2f2f41d83a2fb8fe41b7d4b8feb4fef5998b0ef Mon Sep 17 00:00:00 2001 From: frr <64584192+wuyuanfr@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:59:33 +0800 Subject: [PATCH 001/113] feat(observability): expose context and cache metrics (#3572) * feat(observability): expose context and cache metrics * fix(context): bound evidence fingerprint recursion --- sdk/nexent/core/agents/context/manager.py | 69 ++++++++++++++++--- sdk/nexent/core/agents/core_agent.py | 6 ++ sdk/nexent/core/agents/nexent_agent.py | 48 ++++++++++++- sdk/nexent/core/context_runtime/contracts.py | 10 +++ sdk/nexent/core/models/prompt_cache.py | 20 ++++-- sdk/nexent/monitor/monitoring.py | 50 ++++++++++++++ .../agents/test_context_manager_assembly.py | 37 ++++++++++ test/sdk/core/agents/test_nexent_agent.py | 55 +++++++++++++++ test/sdk/core/models/test_prompt_cache.py | 9 +++ test/sdk/monitor/test_monitoring.py | 26 +++++++ 10 files changed, 316 insertions(+), 14 deletions(-) diff --git a/sdk/nexent/core/agents/context/manager.py b/sdk/nexent/core/agents/context/manager.py index a7117c20e8..973c648562 100644 --- a/sdk/nexent/core/agents/context/manager.py +++ b/sdk/nexent/core/agents/context/manager.py @@ -177,9 +177,13 @@ def assemble_final_context( reasons = self._change_reasons(stable_fp, self._stable_item_fingerprints(final_items, purpose_stable, canonical_tools)) self._previous_stable_fingerprint = stable_fp selected_ids = tuple(item.id for item in final_items) + message_roles = tuple(message_role(message) for message in messages) + system_messages = [message for message in messages if message_role(message) in {"system", "developer"}] + history_messages = [message for message in messages if message_role(message) not in {"system", "developer"}] return FinalContext( messages=messages, tools=canonical_tools, evidence=ContextEvidence( + purpose=purpose, selected_item_ids=selected_ids, selected_item_types=tuple(item.type.value for item in final_items), stable_message_count=len(stable) + len(purpose_stable), @@ -203,6 +207,18 @@ def assemble_final_context( ), representation_cache_hits=hits, representation_cache_misses=misses, compact_exhausted=compact_exhausted, over_hard_budget=over_hard, + messages_fingerprint=self._fingerprint(messages), + tools_fingerprint=self._fingerprint(canonical_tools), + system_messages_fingerprint=self._fingerprint(system_messages), + history_messages_fingerprint=self._fingerprint(history_messages), + final_answer_prompt_fingerprint=( + self._fingerprint(purpose_dynamic) + if purpose == "final_answer" else None + ), + message_roles=message_roles, + history_message_roles=tuple(message_role(message) for message in history_messages), + compression_attempted=bool(self._step_local_log), + fallback_compaction_used=any(representation != "raw" for _, representation in representations), ), ) @@ -392,16 +408,37 @@ def _canonical_tools(tools): return sorted(list(tools), key=lambda tool: json.dumps(ContextManager._normalize(tool), sort_keys=True, default=str)) @staticmethod - def _normalize(value): - if isinstance(value, dict): - return {str(k): ContextManager._normalize(v) for k, v in sorted(value.items(), key=lambda pair: str(pair[0]))} - if isinstance(value, (list, tuple)): - return [ContextManager._normalize(v) for v in value] - if hasattr(value, "model_dump"): - return ContextManager._normalize(value.model_dump()) + def _normalize(value, _active_ids=None, _depth=0): if isinstance(value, (str, int, float, bool)) or value is None: return value - return {"name": getattr(value, "name", value.__class__.__name__)} + class_name = f"{value.__class__.__module__}.{value.__class__.__qualname__}" + if _depth >= 32: + return {"__max_depth__": class_name} + + active_ids = _active_ids if _active_ids is not None else set() + value_id = id(value) + if value_id in active_ids: + return {"__cycle__": class_name} + active_ids.add(value_id) + try: + if isinstance(value, dict): + return { + str(key): ContextManager._normalize(item, active_ids, _depth + 1) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, (list, tuple)): + return [ + ContextManager._normalize(item, active_ids, _depth + 1) + for item in value + ] + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + return ContextManager._normalize( + model_dump(), active_ids, _depth + 1 + ) + return {"name": getattr(value, "name", value.__class__.__name__)} + finally: + active_ids.remove(value_id) @staticmethod def _message_to_dict(message): @@ -428,7 +465,21 @@ def _to_json_value(value): return str(value) def _fingerprint(self, value): - encoded = json.dumps(self._normalize(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + try: + normalized = self._normalize(value) + except Exception as error: + normalized = { + "__normalization_error__": type(error).__name__, + "__class__": ( + f"{value.__class__.__module__}.{value.__class__.__qualname__}" + ), + } + encoded = json.dumps( + normalized, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) return hashlib.sha256(encoded.encode()).hexdigest() def _stable_item_fingerprints(self, items, purpose, tools): diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index c5d6746b6b..4a14e8a597 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -747,6 +747,7 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: current_run_start_idx=self._history_step_count, tools=self._context_tools(), ) + get_monitoring_manager().record_final_context_evidence(final_context.evidence, step_number=self.step_number) self._emit_history_summary_event() self._ensure_context_within_hard_budget(final_context) input_messages = final_context.messages @@ -931,6 +932,10 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: if code_output is not None and code_output.output is not None: truncated_output = truncate_content(str(code_output.output)) observation += "Last output from code snippet:\n" + truncated_output + self.observer.add_message( + self.agent_name, ProcessType.EXECUTION_LOGS, + "Last output from code snippet:\n" + truncated_output, + ) memory_step.observations = observation verification_controller = getattr(self, "verification_controller", None) @@ -1391,6 +1396,7 @@ def _handle_max_steps_reached(self, task: str) -> Any: task=task, final_answer_templates=self.prompt_templates, ) + get_monitoring_manager().record_final_context_evidence(final_context.evidence, step_number=self.step_number) self._emit_history_summary_event() self._ensure_context_within_hard_budget(final_context) messages = final_context.messages diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index d1da018fb8..a4a36cea90 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -371,6 +371,17 @@ def create_local_tool(self, tool_config: ToolConfig): tools_obj = tool_class(**params) if hasattr(tools_obj, 'observer'): tools_obj.observer = self.observer + if tool_config.inputs and hasattr(tools_obj, "inputs"): + parsed_inputs = tool_config.inputs + if isinstance(parsed_inputs, str): + try: + parsed_inputs = json.loads(parsed_inputs) + except (TypeError, ValueError): + parsed_inputs = None + if isinstance(parsed_inputs, dict): + tools_obj.inputs = parsed_inputs + if tool_config.output_type and hasattr(tools_obj, "output_type"): + tools_obj.output_type = tool_config.output_type return tools_obj def create_langchain_tool(self, tool_config: ToolConfig): @@ -786,8 +797,10 @@ def agent_run_with_observer(self, query: str, reset=True): total_output_tokens += step_output estimated_context = None + last_metric = None if hasattr(self.agent, "step_metrics") and self.agent.step_metrics: - estimated_context = self.agent.step_metrics[-1].get( + last_metric = self.agent.step_metrics[-1] + estimated_context = last_metric.get( "memory_state", {} ).get("estimated_input_tokens") @@ -819,6 +832,39 @@ def agent_run_with_observer(self, query: str, reset=True): None, ), } + if last_metric: + compression = last_metric.get("compression", {}) or {} + token_data.update({ + "compression_calls": compression.get("calls", 0), + "compression_input_tokens": compression.get("input_tokens", 0), + "compression_output_tokens": compression.get("output_tokens", 0), + "compression_cache_hits": compression.get("cache_hits", 0), + "compression_cache_types": compression.get("cache_types", []), + "compression_ratio": last_metric.get("compression_ratio", 0.0), + "uncompressed_est_tokens": last_metric.get("uncompressed_mem_est_input", 0), + }) + active_model = getattr(self.agent, "model", None) + cache_usage = getattr(active_model, "last_prompt_cache_usage", None) + cache_advice = getattr(active_model, "last_provider_cache_advice", None) + if cache_usage is not None: + metrics_source = getattr(cache_usage, "metrics_source", "capability_unknown") + metrics_available = metrics_source not in {"none", "capability_unknown"} + capability_supported = bool(getattr(cache_advice, "supported", False)) + token_data.update({ + "provider_cache_status": ( + "available" if metrics_available else + "unavailable" if capability_supported else + "unsupported" + ), + "provider_cache_metrics_source": metrics_source, + "provider_cache_hit": bool(getattr(cache_usage, "provider_cache_hit", False)), + "provider_cached_input_tokens": int( + getattr(cache_usage, "cached_input_tokens", 0) or 0 + ), + "provider_uncached_input_tokens": int( + getattr(cache_usage, "uncached_input_tokens", 0) or 0 + ), + }) observer.add_message("", ProcessType.TOKEN_COUNT, json.dumps(token_data)) if hasattr(step_log, "error") and step_log.error is not None: diff --git a/sdk/nexent/core/context_runtime/contracts.py b/sdk/nexent/core/context_runtime/contracts.py index 6d1756df0f..ba3b86d322 100644 --- a/sdk/nexent/core/context_runtime/contracts.py +++ b/sdk/nexent/core/context_runtime/contracts.py @@ -30,6 +30,7 @@ @dataclass(frozen=True) class ContextEvidence: + purpose: str = "step" selected_item_ids: tuple[str, ...] = () selected_item_types: tuple[str, ...] = () stable_message_count: int = 0 @@ -57,6 +58,15 @@ class ContextEvidence: over_hard_budget: bool = False model_call_count: int = 0 loop_status: str | None = None + messages_fingerprint: str | None = None + tools_fingerprint: str | None = None + system_messages_fingerprint: str | None = None + history_messages_fingerprint: str | None = None + final_answer_prompt_fingerprint: str | None = None + message_roles: tuple[str, ...] = () + history_message_roles: tuple[str, ...] = () + compression_attempted: bool = False + fallback_compaction_used: bool = False @dataclass(frozen=True) diff --git a/sdk/nexent/core/models/prompt_cache.py b/sdk/nexent/core/models/prompt_cache.py index 4d47f5e343..2afa9cb939 100644 --- a/sdk/nexent/core/models/prompt_cache.py +++ b/sdk/nexent/core/models/prompt_cache.py @@ -24,6 +24,11 @@ "serialization_version": "openai_chat_completions.v1", "capability_version": PROMPT_CACHE_CAPABILITY_VERSION, }, + "deepseek": { + "mode": "provider_automatic", "enabled": True, "metrics_available": True, + "serialization_version": "deepseek_chat_completions.v1", + "capability_version": PROMPT_CACHE_CAPABILITY_VERSION, + }, } @@ -123,10 +128,10 @@ def extract_prompt_cache_usage( metrics_source="capability_unknown", ) - cached, source = _extract_cached_input_tokens(usage) + profile = _normalize_capability_profile(capability_profile or {}) + cached, source = _extract_cached_input_tokens(usage, provider=str(profile.get("provider") or "")) uncached = max(0, (input_tokens or 0) - cached) total = cached + uncached - profile = _normalize_capability_profile(capability_profile or {}) discount = profile.get("cached_input_discount", 0.0) try: discount = max(0.0, min(float(discount), 1.0)) @@ -174,8 +179,9 @@ def _directive_advice(profile: Optional[Mapping[str, Any]]) -> CacheDirectiveAdv return CacheDirectiveAdvice(mode=mode, reason="unrecognized_mode") -def _extract_cached_input_tokens(usage: Any) -> Tuple[int, str]: - candidates = ( +def _extract_cached_input_tokens(usage: Any, provider: str = "") -> Tuple[int, str]: + deepseek_candidate = (None, "prompt_cache_hit_tokens", "deepseek_prompt_cache_tokens") + common_candidates = ( ("prompt_tokens_details", "cached_tokens", "openai_prompt_tokens_details"), ("input_tokens_details", "cached_tokens", "openai_input_tokens_details"), ("input_token_details", "cache_read", "anthropic_input_token_details"), @@ -183,6 +189,11 @@ def _extract_cached_input_tokens(usage: Any) -> Tuple[int, str]: (None, "cached_tokens", "top_level_fallback"), (None, "cache_read_input_tokens", "top_level_fallback"), ) + candidates = ( + (deepseek_candidate, *common_candidates) + if provider.lower() == "deepseek" + else (*common_candidates, deepseek_candidate) + ) for parent_name, child_name, source in candidates: value = _get_value(_get_value(usage, parent_name), child_name) if parent_name else _get_value(usage, child_name) if value is None: @@ -227,5 +238,6 @@ def _normalize_for_json(value: Any) -> Any: def _serialization_version(provider: str) -> str: return { "openai": "openai_chat_completions.v1", + "deepseek": "deepseek_chat_completions.v1", "anthropic": "anthropic_messages.v1", }.get((provider or "").lower(), "unknown") diff --git a/sdk/nexent/monitor/monitoring.py b/sdk/nexent/monitor/monitoring.py index 3caf210488..765c09abb9 100644 --- a/sdk/nexent/monitor/monitoring.py +++ b/sdk/nexent/monitor/monitoring.py @@ -1341,6 +1341,56 @@ def record_agent_step_metrics( ) self.add_span_event("agent.step.metrics", attrs) + def record_final_context_evidence(self, evidence: Any, step_number: int) -> None: + """Record a content-free description of the exact model payload.""" + if not self.is_enabled or not OPENTELEMETRY_AVAILABLE: + return + compression_records = [{ + "call_type": getattr(record, "call_type", "unknown"), + "input_tokens": getattr(record, "input_tokens", 0), + "output_tokens": getattr(record, "output_tokens", 0), + "input_chars": getattr(record, "input_chars", 0), + "output_chars": getattr(record, "output_chars", 0), + "cache_hit": bool(getattr(record, "cache_hit", False)), + } for record in (getattr(evidence, "compression_records", ()) or ())] + attrs = { + "agent.step.number": step_number, + "context.purpose": getattr(evidence, "purpose", "step"), + "context.messages.fingerprint": getattr(evidence, "messages_fingerprint", "") or "", + "context.tools.fingerprint": getattr(evidence, "tools_fingerprint", "") or "", + "context.messages.system_fingerprint": getattr(evidence, "system_messages_fingerprint", "") or "", + "context.messages.history_fingerprint": getattr(evidence, "history_messages_fingerprint", "") or "", + "context.final_answer_prompt.fingerprint": getattr(evidence, "final_answer_prompt_fingerprint", "") or "", + "context.message.roles": json.dumps( + list(getattr(evidence, "message_roles", ()) or ()), + ensure_ascii=False, + ), + "context.history_message.roles": json.dumps( + list(getattr(evidence, "history_message_roles", ()) or ()), + ensure_ascii=False, + ), + "context.items.selected": json.dumps( + list(getattr(evidence, "selected_item_types", ()) or ()), + ensure_ascii=False, + ), + "context.messages.stable_count": getattr(evidence, "stable_message_count", 0), + "context.messages.dynamic_count": getattr(evidence, "dynamic_message_count", 0), + "context.stable_prefix.fingerprint": getattr(evidence, "stable_prefix_fingerprint", "") or "", + "context.stable_prefix.change_reasons": json.dumps( + list(getattr(evidence, "prefix_change_reasons", ()) or ()), + ensure_ascii=False, + ), + "context.budget.soft": getattr(evidence, "soft_budget", 0), + "context.budget.hard": getattr(evidence, "hard_budget", 0), + "context.tokens.pre_compression": getattr(evidence, "raw_token_estimate", 0), + "context.tokens.post_compression": getattr(evidence, "final_token_estimate", 0), + "context.budget.hard_exceeded": bool(getattr(evidence, "over_hard_budget", False)), + "context.compression.attempted": bool(getattr(evidence, "compression_attempted", False)), + "context.compression.fallback_compaction": bool(getattr(evidence, "fallback_compaction_used", False)), + "context.compression.records": json.dumps(compression_records, ensure_ascii=False, sort_keys=True), + } + self.add_span_event("agent.final_context", attrs) + def set_agent_context_metrics(self, metrics: List[Dict[str, Any]]) -> None: """Attach aggregate context/compression metrics to the current Agent span.""" if not metrics: diff --git a/test/sdk/core/agents/test_context_manager_assembly.py b/test/sdk/core/agents/test_context_manager_assembly.py index 67153d9f45..a3c346457d 100644 --- a/test/sdk/core/agents/test_context_manager_assembly.py +++ b/test/sdk/core/agents/test_context_manager_assembly.py @@ -80,9 +80,44 @@ def test_context_manager_assembles_stable_dynamic_and_history_messages(): assert final.evidence.stable_message_count == 1 assert final.evidence.dynamic_message_count == 3 assert final.evidence.stable_prefix_fingerprint + assert final.evidence.purpose == "step" + assert final.evidence.messages_fingerprint + assert final.evidence.tools_fingerprint + assert final.evidence.system_messages_fingerprint + assert final.evidence.history_messages_fingerprint + assert final.evidence.message_roles == ("system", "user", "user", "user") + assert final.evidence.history_message_roles == ("user", "user", "user") assert final.tools == [{"name": "a"}, {"name": "z"}] +def test_context_fingerprint_bounds_cycles_and_excessive_depth(): + manager = ContextManager() + cyclic = {} + cyclic["self"] = cyclic + deeply_nested = current = {} + for _ in range(40): + child = {} + current["child"] = child + current = child + + normalized_cycle = manager._normalize(cyclic) + normalized_depth = manager._normalize(deeply_nested) + + assert normalized_cycle["self"]["__cycle__"] == "builtins.dict" + assert "__max_depth__" in str(normalized_depth) + assert len(manager._fingerprint(cyclic)) == 64 + + +def test_context_fingerprint_degrades_when_normalization_fails(): + class BrokenDump: + def model_dump(self): + raise RuntimeError("broken observational payload") + + fingerprint = ContextManager()._fingerprint([BrokenDump()]) + + assert len(fingerprint) == 64 + + def test_prepare_run_projects_fallback_system_prompt_without_mutating_memory(): manager = ContextManager(ContextManagerConfig(token_threshold=10000)) memory = _Memory() @@ -141,6 +176,8 @@ def test_context_manager_owns_final_answer_assembly(): "final instruction", "memory fact", ] + assert final.evidence.purpose == "final_answer" + assert final.evidence.final_answer_prompt_fingerprint assert _message_text(final.messages[-1]) == "answer task: original task" assert final.evidence.stable_message_count == 2 assert "context_purpose" in final.evidence.prefix_change_reasons or ( diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index 6737c0e237..3ad5408a76 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -887,6 +887,8 @@ def test_create_local_tool_success(nexent_agent_instance): mock_tool_class.assert_called_once_with(param1="value1", param2=42) assert result == mock_tool_instance + assert result.inputs == {} + assert result.output_type == "string" def test_create_local_tool_analyze_text_file_tool(nexent_agent_instance): @@ -1684,6 +1686,59 @@ def __exit__(self, exc_type, exc, tb): mock_print.assert_not_called() +def test_agent_run_with_observer_forwards_compression_and_provider_cache_metrics( + nexent_agent_instance, mock_core_agent, +): + nexent_agent_instance.agent = mock_core_agent + nexent_agent_instance._log_step_metrics = MagicMock() + mock_core_agent.stop_event.is_set.return_value = False + mock_core_agent.step_metrics = [{ + "step_number": 1, + "timestamp": 0.0, + "main_llm": {"input_tokens": 100, "output_tokens": 5}, + "compression": { + "calls": 2, + "input_tokens": 100, + "output_tokens": 40, + "cache_hits": 1, + "cache_types": ["summary"], + }, + "memory_state": {"estimated_input_tokens": 60, "estimated_output_tokens": 5}, + "compression_ratio": 40.0, + "uncompressed_mem_est_input": 100, + "cache_hit": True, + "cache_types": ["summary"], + }] + mock_core_agent.model = types.SimpleNamespace( + last_provider_cache_advice=types.SimpleNamespace(supported=True), + last_prompt_cache_usage=types.SimpleNamespace( + metrics_source="openai_prompt_tokens_details", provider_cache_hit=True, + cached_input_tokens=40, uncached_input_tokens=60, + ), + ) + mock_action_step = MagicMock(spec=ActionStep) + mock_action_step.timing = MagicMock(duration=1.0) + mock_action_step.step_number = 1 + mock_action_step.error = None + mock_action_step.output = "answer" + mock_action_step.token_usage = types.SimpleNamespace( + input_tokens=100, + output_tokens=5, + ) + mock_core_agent.run.return_value = [mock_action_step] + nexent_agent_instance.agent_run_with_observer("test query") + payload = [ + json.loads(call.args[2]) for call in mock_core_agent.observer.add_message.call_args_list + if len(call.args) >= 3 and call.args[1] == ProcessType.TOKEN_COUNT + ][-1] + assert payload["compression_calls"] == 2 + assert payload["compression_cache_hits"] == 1 + assert payload["provider_cache_status"] == "available" + assert payload["provider_cache_hit"] is True + assert payload["provider_cached_input_tokens"] == 40 + assert payload["provider_uncached_input_tokens"] == 60 + + def test_agent_run_with_observer_success_with_string_final_answer(nexent_agent_instance, mock_core_agent): """Test successful agent_run_with_observer with string final answer.""" # Setup diff --git a/test/sdk/core/models/test_prompt_cache.py b/test/sdk/core/models/test_prompt_cache.py index e563ae569f..45f22daff7 100644 --- a/test/sdk/core/models/test_prompt_cache.py +++ b/test/sdk/core/models/test_prompt_cache.py @@ -109,3 +109,12 @@ def test_missing_metrics_never_reports_a_provider_cache_hit(): assert result.cached_input_tokens == 0 assert result.provider_cache_hit is False assert result.metrics_source == "capability_unknown" + + +def test_deepseek_profile_extracts_prompt_cache_hit_tokens(): + profile = resolve_prompt_cache_profile("deepseek") + result = extract_prompt_cache_usage({"prompt_cache_hit_tokens": 75}, 100, capability_profile=profile) + assert profile["serialization_version"] == "deepseek_chat_completions.v1" + assert result.cached_input_tokens == 75 + assert result.uncached_input_tokens == 25 + assert result.metrics_source == "deepseek_prompt_cache_tokens" diff --git a/test/sdk/monitor/test_monitoring.py b/test/sdk/monitor/test_monitoring.py index e886323481..b059ae8ff1 100644 --- a/test/sdk/monitor/test_monitoring.py +++ b/test/sdk/monitor/test_monitoring.py @@ -954,6 +954,32 @@ def test_set_agent_context_metrics_adds_aggregate_attributes(self, mock_trace): assert attrs["context.compression.calls.total"] == 3 assert attrs["context.compression.cache_hits.total"] == 1 + @patch('sdk.nexent.monitor.monitoring.trace') + def test_record_final_context_evidence_adds_content_free_event(self, mock_trace): + from nexent.core.context_runtime.contracts import ContextEvidence + with patch('sdk.nexent.monitor.monitoring.OPENTELEMETRY_AVAILABLE', True): + manager = self._enabled_manager() + mock_span = MagicMock() + mock_trace.get_current_span.return_value = mock_span + evidence = ContextEvidence( + purpose="final_answer", selected_item_types=("system", "conversation_turn"), + stable_message_count=1, dynamic_message_count=2, + stable_prefix_fingerprint="stable-fp", prefix_change_reasons=("initial_request",), + soft_budget=100, hard_budget=120, raw_token_estimate=110, final_token_estimate=80, + messages_fingerprint="messages-fp", tools_fingerprint="tools-fp", + message_roles=("system", "user", "assistant"), + history_message_roles=("user", "assistant"), compression_attempted=True, + ) + manager.record_final_context_evidence(evidence, step_number=3) + event_name, event_attrs = mock_span.add_event.call_args.args + assert event_name == "agent.final_context" + assert event_attrs["agent.step.number"] == 3 + assert event_attrs["context.messages.fingerprint"] == "messages-fp" + assert event_attrs["context.budget.soft"] == 100 + assert event_attrs["context.tokens.pre_compression"] == 110 + assert event_attrs["context.tokens.post_compression"] == 80 + assert event_attrs["context.compression.attempted"] is True + class TestLLMTokenTracker: """Test LLMTokenTracker with OpenInference semantics.""" From b3b881f3788477f06ce18b9067443ad7db92e955 Mon Sep 17 00:00:00 2001 From: hhhhsc701 <56435672+hhhhsc701@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:23:18 +0800 Subject: [PATCH 002/113] Implement named offline package uploads to Huawei OBS (#3608) * Add named offline package uploads to Huawei OBS * Trigger offline package builds from version tags * Update offline package artifact upload * Gate offline package uploads to OBS --------- Co-authored-by: hhhhsc --- .github/workflows/build-offline-package.yml | 61 +++++++++++++++------ deploy/offline/build_offline_package.sh | 26 +++++++++ deploy/tests/test_build_offline_package.sh | 30 ++++++++-- 3 files changed, 97 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-offline-package.yml b/.github/workflows/build-offline-package.yml index 54a6c4caf2..34822c9dc9 100644 --- a/.github/workflows/build-offline-package.yml +++ b/.github/workflows/build-offline-package.yml @@ -1,12 +1,14 @@ name: Build Offline Deployment Package on: + push: + tags: + - 'v*' workflow_dispatch: inputs: version: - description: 'Image version tag, e.g. v2.2.0 or latest' + description: 'Image version tag; leave blank to use the selected Git tag or branch' required: false - default: 'latest' image_source: description: 'Image source' required: false @@ -20,6 +22,11 @@ on: required: false default: false type: boolean + upload_to_obs: + description: 'Upload the final package to Huawei Cloud OBS' + required: false + default: false + type: boolean jobs: build-offline-package: @@ -71,13 +78,19 @@ jobs: fi SOURCE_SUFFIX="" - if [ "${{ inputs.include_source }}" = "true" ]; then + INCLUDE_SOURCE="${{ inputs.include_source || false }}" + IMAGE_SOURCE="${{ inputs.image_source || 'general' }}" + UPLOAD_TO_OBS="${{ (github.event_name == 'push' && github.ref_type == 'tag') || inputs.upload_to_obs }}" + if [ "$INCLUDE_SOURCE" = "true" ]; then SOURCE_SUFFIX="-with-source" fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "platform=$PLATFORM" >> $GITHUB_OUTPUT - echo "package-name=nexent-${VERSION}-${PLATFORM}${SOURCE_SUFFIX}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + echo "include-source=$INCLUDE_SOURCE" >> "$GITHUB_OUTPUT" + echo "image-source=$IMAGE_SOURCE" >> "$GITHUB_OUTPUT" + echo "upload_to_obs=$UPLOAD_TO_OBS" >> "$GITHUB_OUTPUT" + echo "package-name=nexent-${VERSION}-${PLATFORM}${SOURCE_SUFFIX}" >> "$GITHUB_OUTPUT" - name: Set deployment components id: set-components @@ -95,11 +108,12 @@ jobs: --version "${{ steps.set-vars.outputs.version }}" \ --platform "${{ steps.set-vars.outputs.platform }}" \ --output-dir ./offline-output \ - --include-source "${{ inputs.include_source }}" \ - --image-source "${{ inputs.image_source }}" \ + --include-source "${{ steps.set-vars.outputs.include-source }}" \ + --image-source "${{ steps.set-vars.outputs.image-source }}" \ --components "${{ steps.set-components.outputs.components }}" \ --target all \ - --compress false + --package-name "${{ steps.set-vars.outputs.package-name }}" \ + --compress true - name: Show offline package run: | @@ -111,14 +125,30 @@ jobs: du -sh ./offline-output find ./offline-output -maxdepth 2 -type f | sort | head -50 + - name: Authenticate to Huawei Cloud + if: ${{ steps.set-vars.outputs.upload_to_obs == 'true' }} + uses: huaweicloud/auth-action@v1.1.0 + with: + access_key_id: ${{ secrets.HUAWEI_OBS_ACCESSKEY }} + secret_access_key: ${{ secrets.HUAWEI_OBS_SECRETKEY }} + region: 'cn-east-3' + + - name: Upload to Huawei Cloud OBS + if: ${{ steps.set-vars.outputs.upload_to_obs == 'true' }} + uses: huaweicloud/obs-helper@v1.0.0 + with: + bucket_name: 'nexent-images' + local_file_path: './${{ steps.set-vars.outputs.package-name }}.zip' + obs_file_path: 'packages/${{ steps.set-vars.outputs.package-name }}.zip' + operation_type: 'upload' + - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: ${{ steps.set-vars.outputs.package-name }} - path: ./offline-output + path: './${{ steps.set-vars.outputs.package-name }}.zip' if-no-files-found: error - include-hidden-files: true retention-days: 30 + archive: false - name: Summary run: | @@ -129,10 +159,9 @@ jobs: echo "Version: ${{ steps.set-vars.outputs.version }}" echo "Platform: ${{ steps.set-vars.outputs.platform }}" echo "Package: ${{ steps.set-vars.outputs.package-name }}.zip" - echo "Note: the downloaded artifact zip contains the offline package contents directly." - echo "Target: ${{ inputs.target }}" + echo "Note: the GitHub artifact zip contains the offline package contents directly." echo "Components: ${{ steps.set-components.outputs.components }}" - echo "Image source: ${{ inputs.image_source }}" + echo "Image source: ${{ steps.set-vars.outputs.image-source }}" echo "Ref Type: ${{ github.ref_type }}" echo "Ref Name: ${{ github.ref_name }}" echo "========================================" diff --git a/deploy/offline/build_offline_package.sh b/deploy/offline/build_offline_package.sh index 05bc767827..e23e7a4d76 100755 --- a/deploy/offline/build_offline_package.sh +++ b/deploy/offline/build_offline_package.sh @@ -23,6 +23,7 @@ INCLUDE_SOURCE="" INCLUDE_SANDBOX="" TARGET="" COMPRESS="" +PACKAGE_NAME="" DRY_RUN="false" COMMON_ARGS=() @@ -60,6 +61,8 @@ show_help() { echo " 默认:$DEFAULT_TARGET" echo " --compress BOOL 构建后是否创建 zip 压缩包(true 或 false)" echo " 默认:$DEFAULT_COMPRESS" + echo " --package-name NAME 最终 zip 包名称(可省略 .zip 后缀)" + echo " 默认:根据目标、平台和版本自动生成" echo " --components LIST 用于镜像选择的部署组件" echo " --image-source SOURCE general、mainland 或 local-latest" echo " --registry-profile NAME 兼容旧参数,映射到 --image-source general|mainland" @@ -96,6 +99,8 @@ show_help() { echo " Default: $DEFAULT_TARGET" echo " --compress BOOL Create zip archive after package build (true or false)" echo " Default: $DEFAULT_COMPRESS" + echo " --package-name NAME Final zip package name (.zip suffix is optional)" + echo " Default: generated from target, platform, and version" echo " --components LIST Deployment components for image selection" echo " --image-source SOURCE general, mainland, or local-latest" echo " --registry-profile NAME Legacy alias for --image-source general|mainland" @@ -145,6 +150,10 @@ parse_args() { COMPRESS="$2" shift 2 ;; + --package-name) + PACKAGE_NAME="$2" + shift 2 + ;; --dry-run) DRY_RUN="true" shift @@ -184,6 +193,7 @@ parse_args() { INCLUDE_SANDBOX="${INCLUDE_SANDBOX:-$DEFAULT_INCLUDE_SANDBOX}" TARGET="${TARGET:-$DEFAULT_TARGET}" COMPRESS="${COMPRESS:-$DEFAULT_COMPRESS}" + PACKAGE_NAME="${PACKAGE_NAME%.zip}" if [[ "$PLATFORM" != "amd64" && "$PLATFORM" != "arm64" ]]; then if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then @@ -217,6 +227,14 @@ parse_args() { fi exit 1 fi + if [[ -n "$PACKAGE_NAME" && ! "$PACKAGE_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + if [ "$DEPLOYMENT_LANGUAGE" = "zh" ]; then + echo "错误:Package name 只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头" + else + echo "Error: Package name may contain only letters, numbers, dots, underscores, and hyphens, and must start with a letter or number" + fi + exit 1 + fi } prepare_deployment_image_config() { @@ -245,6 +263,7 @@ show_dry_run_plan() { echo "包含 Sandbox 镜像:$INCLUDE_SANDBOX" echo "目标:$TARGET" echo "压缩:$COMPRESS" + echo "最终包名称:$(offline_package_name).zip" echo "组件:$DEPLOYMENT_COMPONENTS" echo "镜像源:$DEPLOYMENT_IMAGE_SOURCE" [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "镜像仓库前缀:$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" @@ -265,6 +284,7 @@ show_dry_run_plan() { echo "Include Sandbox image: $INCLUDE_SANDBOX" echo "Target: $TARGET" echo "Compress: $COMPRESS" + echo "Package name: $(offline_package_name).zip" echo "Components: $DEPLOYMENT_COMPONENTS" echo "Image source: $DEPLOYMENT_IMAGE_SOURCE" [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "Image registry prefix: $DEPLOYMENT_IMAGE_REGISTRY_PREFIX" @@ -713,6 +733,11 @@ create_checksums() { } offline_package_name() { + if [[ -n "$PACKAGE_NAME" ]]; then + echo "$PACKAGE_NAME" + return + fi + local safe_version="${VERSION//\//-}" echo "nexent-offline-${TARGET}-${PLATFORM}-${safe_version}" } @@ -764,6 +789,7 @@ main() { echo "Include source: $INCLUDE_SOURCE" echo "Target: $TARGET" echo "Compress: $COMPRESS" + echo "Package name: $(offline_package_name).zip" echo "Components: $DEPLOYMENT_COMPONENTS" echo "Image source: $DEPLOYMENT_IMAGE_SOURCE" [ -n "$DEPLOYMENT_IMAGE_REGISTRY_PREFIX" ] && echo "Image registry prefix: $DEPLOYMENT_IMAGE_REGISTRY_PREFIX" diff --git a/deploy/tests/test_build_offline_package.sh b/deploy/tests/test_build_offline_package.sh index f3e45b4c55..9d0a2eda25 100755 --- a/deploy/tests/test_build_offline_package.sh +++ b/deploy/tests/test_build_offline_package.sh @@ -106,15 +106,30 @@ assert_common_package_files() { create_fake_docker WORKFLOW_CONTENT="$(cat "$PROJECT_ROOT/.github/workflows/build-offline-package.yml")" +echo "$WORKFLOW_CONTENT" | grep -A2 '^ push:$' | grep -q -- "- 'v\*'" || fail "offline package workflow should run automatically for version tags" +! echo "$WORKFLOW_CONTENT" | grep -A4 '^ version:$' | grep -q "default: 'latest'" || fail "offline package workflow version input should defer to the selected ref" +echo "$WORKFLOW_CONTENT" | grep -q 'elif \[ "$REF_TYPE" = "tag" \]; then' || fail "offline package workflow should resolve the package version from a tag" +echo "$WORKFLOW_CONTENT" | grep -q 'VERSION="$REF_NAME"' || fail "offline package workflow should use the tag name as the package version" +echo "$WORKFLOW_CONTENT" | grep -q "IMAGE_SOURCE=\"\${{ inputs.image_source || 'general' }}\"" || fail "tag builds should default to the general image source" +echo "$WORKFLOW_CONTENT" | grep -A4 '^ upload_to_obs:$' | grep -q 'default: false' || fail "manual offline package builds should not upload to OBS by default" +echo "$WORKFLOW_CONTENT" | grep -A4 '^ upload_to_obs:$' | grep -q 'type: boolean' || fail "OBS upload input should be a checkbox" +echo "$WORKFLOW_CONTENT" | grep -q "UPLOAD_TO_OBS=\"\${{ (github.event_name == 'push' && github.ref_type == 'tag') || inputs.upload_to_obs }}\"" || fail "tag-triggered builds should enable OBS upload" +echo "$WORKFLOW_CONTENT" | grep -A2 -- '- name: Authenticate to Huawei Cloud' | grep -q "if: \${{ steps.set-vars.outputs.upload_to_obs == 'true' }}" || fail "Huawei Cloud authentication should honor the OBS upload switch" +echo "$WORKFLOW_CONTENT" | grep -A2 -- '- name: Upload to Huawei Cloud OBS' | grep -q "if: \${{ steps.set-vars.outputs.upload_to_obs == 'true' }}" || fail "Huawei Cloud OBS upload should honor the OBS upload switch" echo "$WORKFLOW_CONTENT" | grep -q 'SOURCE_SUFFIX="-with-source"' || fail "offline package workflow should append with-source when source is included" echo "$WORKFLOW_CONTENT" | grep -q 'package-name=nexent-${VERSION}-${PLATFORM}${SOURCE_SUFFIX}' || fail "offline package workflow package name should include source suffix" -echo "$WORKFLOW_CONTENT" | grep -q -- '--compress false' || fail "offline package workflow should let GitHub create the final artifact zip" -echo "$WORKFLOW_CONTENT" | grep -q 'path: ./offline-output' || fail "offline package workflow should upload package contents, not an inner zip" -! echo "$WORKFLOW_CONTENT" | grep -q 'path: .*package-name.*\\.zip' || fail "offline package workflow should not upload a pre-compressed zip" +echo "$WORKFLOW_CONTENT" | grep -q -- '--package-name "${{ steps.set-vars.outputs.package-name }}"' || fail "offline package workflow should pass the final package name to the build script" +echo "$WORKFLOW_CONTENT" | grep -q -- '--compress true' || fail "offline package workflow should create the named final zip" +echo "$WORKFLOW_CONTENT" | grep -q "local_file_path: './\${{ steps.set-vars.outputs.package-name }}.zip'" || fail "offline package workflow should upload the named zip to OBS" +echo "$WORKFLOW_CONTENT" | grep -q "obs_file_path: 'packages/\${{ steps.set-vars.outputs.package-name }}.zip'" || fail "offline package workflow should preserve the named zip in OBS" +echo "$WORKFLOW_CONTENT" | grep -q 'uses: actions/upload-artifact@v7' || fail "offline package workflow should use upload-artifact v7 for unarchived uploads" +echo "$WORKFLOW_CONTENT" | grep -q "^[[:space:]]*path: './\${{ steps.set-vars.outputs.package-name }}.zip'" || fail "offline package workflow should upload the named zip artifact" +echo "$WORKFLOW_CONTENT" | grep -q '^[[:space:]]*archive: false' || fail "offline package workflow should upload the zip without adding another archive layer" echo "$WORKFLOW_CONTENT" | grep -q 'COMPONENTS="infrastructure,application,data-process,supabase,terminal"' || fail "offline package workflow should select all packageable components" OFFLINE_HELP="$(DEPLOYMENT_LANG=en bash "$PROJECT_ROOT/deploy/offline/build_offline_package.sh" --help)" echo "$OFFLINE_HELP" | grep -q -- '--include-sandbox BOOL' || fail "offline package help should document --include-sandbox" +echo "$OFFLINE_HELP" | grep -q -- '--package-name NAME' || fail "offline package help should document --package-name" SANDBOX_DRY_RUN="$(DEPLOYMENT_LANG=en bash "$PROJECT_ROOT/deploy/offline/build_offline_package.sh" --version v2.2.0 --platform amd64 --components infrastructure,application --image-source general --target docker --dry-run)" echo "$SANDBOX_DRY_RUN" | grep -q 'Include Sandbox image: true' || fail "offline dry-run should show that the Sandbox image is enabled by default" @@ -129,6 +144,11 @@ if DEPLOYMENT_LANG=en bash "$PROJECT_ROOT/deploy/offline/build_offline_package.s fi grep -q "Include sandbox must be 'true' or 'false'" "$TMP_DIR/invalid-include-sandbox.log" || fail "invalid --include-sandbox error should be explicit" +if DEPLOYMENT_LANG=en bash "$PROJECT_ROOT/deploy/offline/build_offline_package.sh" --package-name ../invalid --dry-run >"$TMP_DIR/invalid-package-name.log" 2>&1; then + fail "--package-name should reject path traversal" +fi +grep -q "Package name may contain only" "$TMP_DIR/invalid-package-name.log" || fail "invalid --package-name error should be explicit" + for target in docker k8s all; do package_dir="$OUT_DIR/$target" PATH="$BIN_DIR:$PATH" \ @@ -302,6 +322,7 @@ PATH="$BIN_DIR:$PATH" FAKE_DOCKER_LOG="$latest_pull_log" \ --image-source general \ --target docker \ --compress true \ + --package-name nexent-custom-latest.zip \ --output-dir "$latest_package_dir" >/tmp/nexent-offline-package-latest.log assert_common_package_files "$latest_package_dir" @@ -507,7 +528,8 @@ second_line="$(sed -n '2p' "$offline_deploy_log")" [[ "$first_line" == "push:secret:--image-registry-prefix registry.local/nexent --load-images" ]] || fail "offline deploy.sh --push-images should push before deploy" [ "$second_line" = "deploy:defaults:true:docker --foo bar --image-registry-prefix registry.local/nexent" ] || fail "offline deploy.sh --push-images should preserve defaults mode and forward registry prefix" -[ -f "$OUT_DIR/nexent-offline-docker-amd64-latest.zip" ] || fail "zip package should be created for latest package" +[ -f "$OUT_DIR/nexent-custom-latest.zip" ] || fail "zip package should use the requested final package name" +[ ! -f "$OUT_DIR/nexent-offline-docker-amd64-latest.zip" ] || fail "custom package name should replace the generated package name" grep -q "nexent/nexent:latest" "$latest_package_dir/manifest.yaml" || fail "manifest should include local latest Nexent image" grep -q '^pull .*nexent/nexent:latest$' "$latest_pull_log" || fail "latest Nexent image should be pulled" grep -q '^pull .*nexent/nexent-web:latest$' "$latest_pull_log" || fail "latest Nexent web image should be pulled" From da53855946a0a3597dbba309bbd739d72cf9aa7c Mon Sep 17 00:00:00 2001 From: xuyaqi Date: Fri, 7 Aug 2026 14:35:07 +0800 Subject: [PATCH 003/113] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E9=87=8D=E5=91=BD=E5=90=8Dconversation?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E7=9A=84=E9=97=AE=E9=A2=98=20(#3613)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversation-thread-list-adapter.tsx | 17 ++++--- .../newchat/assistant-ui/thread-list.tsx | 44 ++++++++++--------- 2 files changed, 34 insertions(+), 27 deletions(-) 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 4c91a8913f..ed8e494ea4 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -1249,17 +1249,20 @@ const waitForServerConversationId = async ( const { idsRef, getActiveThreadId } = state; const startedAt = Date.now(); - // Fast path: the ref is already populated (subsequent runs in a thread - // that already has a server-side conversation, or an existing thread - // opened from the sidebar). + const isValidConversationId = (value: string | undefined): value is string => + Boolean(value) && Number.isInteger(Number(value)) && Number(value) > 0; + + // Existing threads already have a server id in `remoteId`, which is more + // reliable than the active-thread registry while the sidebar is switching. + if (isValidConversationId(fallbackRemoteId)) return fallbackRemoteId; + + // Fast path: the ref is already populated for a new thread after its first + // agent run has returned the server-side conversation ID. const readNow = (): string | undefined => { const activeThreadId = getActiveThreadId(); if (!activeThreadId) return undefined; const fromRef = idsRef.current.get(activeThreadId); - if (fromRef && Number.isInteger(Number(fromRef)) && Number(fromRef) > 0) { - return fromRef; - } - return undefined; + return isValidConversationId(fromRef) ? fromRef : undefined; }; const immediate = readNow(); diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx index 2174c29e39..c38d2fdae1 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx @@ -285,19 +285,20 @@ const ThreadListItemContent: FC = ({ const { t } = useTranslation(); const { confirm } = useConfirmModal(); const [isEditing, setIsEditing] = useState(false); - const thread = aui.threadListItem.getState(); + const threadListItem = aui.threadListItem(); + const thread = threadListItem.getState(); const title = generatedTitles?.get(thread.id) ?? thread.title ?? t("chat.thread.newChat"); const handleRename = useCallback(async (newTitle: string) => { try { - await aui.threadListItem.rename(newTitle); + await threadListItem.rename(newTitle); log.log(`[ThreadList] Renamed thread to "${newTitle}"`); setIsEditing(false); } catch (error) { log.error("[ThreadList] Failed to rename thread:", error); message.error(t("chat.threadList.renameFailed")); } - }, [aui, t]); + }, [threadListItem, t]); const handleRenameClick = useCallback(() => { setIsEditing(true); @@ -309,16 +310,16 @@ const ThreadListItemContent: FC = ({ return ( <> - -
- {isEditing ? ( - - ) : ( - <> + {isEditing ? ( + + ) : ( + <> + +
@@ -332,10 +333,10 @@ const ThreadListItemContent: FC = ({ {title} - - )} -
-
+
+
+ + )} {!isEditing && ( @@ -358,7 +359,7 @@ const ThreadListItemContent: FC = ({ confirm({ title: t("chat.threadList.delete"), content: t("chat.threadList.confirmDeletionDescription"), - onOk: () => aui.threadListItem.delete(), + onOk: () => aui.threadListItem().delete(), }); }} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-destructive/10" @@ -378,7 +379,7 @@ const ConversationStatusIndicatorWrapper: FC<{ completedConversations: Set; }> = ({ completedConversations }) => { const aui = useAui(); - const status = aui.threadListItem.getState().status as string; + const status = aui.threadListItem().getState().status as string; const isRunning = status === "running" || status === "streaming"; return ( @@ -419,7 +420,10 @@ const InlineRenameEditor: FC<{ ); return ( -
+ Date: Mon, 10 Aug 2026 16:29:20 +0800 Subject: [PATCH 004/113] fix: provide local import allowlist before code execution (#3614) * fix: guide agents on Python import whitelist * test: cover restricted Python import guidance * refactor: derive local import guidance from SDK --- backend/agents/create_agent_info.py | 15 ++++++- backend/utils/context_utils.py | 41 +++++++++++++++++++ sdk/nexent/core/agents/nexent_agent.py | 8 ++++ test/backend/agents/test_create_agent_info.py | 41 +++++++++++++++++++ test/backend/utils/test_context_utils.py | 23 +++++++++++ test/sdk/core/agents/test_nexent_agent.py | 14 ++++++- 6 files changed, 140 insertions(+), 2 deletions(-) diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 474044f891..e97c3c2afe 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -2,6 +2,7 @@ import copy import json import logging +import os import threading from typing import Any, Dict, List, Optional from urllib.parse import urljoin @@ -27,6 +28,7 @@ ) from nexent.core.tools.parallel_executor import ParallelExecutorTool from nexent.core.agents.sandbox import SandboxConfig +from nexent.core.agents.nexent_agent import get_local_python_authorized_imports from consts.capability_profiles import CATALOG as CAPABILITY_CATALOG @@ -76,7 +78,6 @@ logger = logging.getLogger("create_agent_info") logger.setLevel(logging.INFO) - def _create_fixed_search_memory_tool(): """Create the internal search tool lazily to keep import boundaries stable.""" from nexent.core.tools.search_memory_tool import SearchMemoryTool @@ -1201,6 +1202,15 @@ async def create_agent_config( else input_budget ) + sandbox_policy = agent_info.get("sandbox_policy") + configured_sandbox_level = ( + sandbox_policy.get("level") if isinstance(sandbox_policy, dict) else None + ) + is_local_python_executor = ( + str(configured_sandbox_level or os.getenv("NEXENT_SANDBOX_DEFAULT_LEVEL", "local")) + .strip().lower() == "local" + ) + context_items = build_context_inputs( duty=duty_prompt, constraint=constraint_prompt, @@ -1222,6 +1232,9 @@ async def create_agent_config( long_term_memory_prompt=long_term_memory_prompt, knowledge_base_summary=knowledge_base_summary, kb_ids=kb_ids, + restricted_python_authorized_imports=( + get_local_python_authorized_imports() if is_local_python_executor else None + ), ) logger.debug( diff --git a/backend/utils/context_utils.py b/backend/utils/context_utils.py index f839747152..a589691a61 100644 --- a/backend/utils/context_utils.py +++ b/backend/utils/context_utils.py @@ -337,6 +337,36 @@ def _build_code_norms_text( return content +def _build_restricted_python_execution_policy_text( + authorized_imports: List[str], + language: str = "zh", +) -> str: + """Build pre-execution guidance for the restricted local interpreter.""" + normalized_imports = sorted({ + name.strip() + for name in authorized_imports + if isinstance(name, str) and name.strip() + }) + imports = ", ".join(f"`{name}`" for name in normalized_imports) + if language == "zh": + lines = ["### Python 代码执行边界"] + lines.append("当前代码执行器是受限解释器。写入可执行代码前,必须遵守以下规则:") + lines.append(f"1. 仅允许导入这些模块:{imports}。") + lines.append("2. 不要导入、安装、探测或依次尝试列表以外的库;`requests`、`urllib`、`pandas`、`numpy`、`openpyxl` 等均不可假定可用。") + lines.append("3. Python 包不是工具。只能调用“可用资源”中实际列出的工具或助手;不要把未定义的包函数(例如 `requests.get`)传给 `parallel_executor`。") + lines.append("4. 受限 Python 没有通用网络、Shell 或包安装能力。若任务需要这些能力而可用资源中没有对应工具,应直接如实说明限制。") + lines.append("5. 本规则优先于“不要放弃”等一般性要求:能力不存在时不要继续猜测替代库或重复失败的执行。") + else: + lines = ["### Python Code Execution Boundary"] + lines.append("The current code executor is a restricted interpreter. Before writing executable code, follow these rules:") + lines.append(f"1. You may import only: {imports}.") + lines.append("2. Do not import, install, probe, or try alternate libraries outside this list; do not assume `requests`, `urllib`, `pandas`, `numpy`, or `openpyxl` is available.") + lines.append("3. A Python package is not a tool. Call only tools or agents actually listed in Available Resources; never pass an undefined package function such as `requests.get` to `parallel_executor`.") + lines.append("4. Restricted Python has no general network, shell, or package-install capability. If a task needs one and no listed tool provides it, state the limitation directly.") + lines.append("5. This policy takes precedence over general instructions to keep trying: do not guess alternate libraries or repeat failed executions when the capability is unavailable.") + return "\n".join(lines) + + def _build_footer_text( few_shots: str, language: str = "zh", @@ -400,6 +430,7 @@ def build_context_inputs( long_term_memory_prompt: Optional[str] = None, knowledge_base_summary: Optional[str] = None, kb_ids: Optional[List[str]] = None, + restricted_python_authorized_imports: Optional[List[str]] = None, include_tools: bool = True, include_skills: bool = True, include_memory: bool = True, @@ -554,6 +585,16 @@ def add_system( )) if constraint: add_system("constraint", _build_constraint_text(constraint, language), 30) + if restricted_python_authorized_imports: + add_system( + "restricted_python_execution", + _build_restricted_python_execution_policy_text( + restricted_python_authorized_imports, + language, + ), + 25, + "platform", + ) add_system("code_norms", _build_code_norms_text(language, is_manager), 20, "platform") if few_shots: add_system("footer", _build_footer_text(few_shots, language), 10) diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index a4a36cea90..4f3dd7ed9f 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -37,6 +37,14 @@ "uuid", "pprint", "operator", "typing", ] + +def get_local_python_authorized_imports() -> List[str]: + """Return the imports permitted by Nexent's default local code executor.""" + from smolagents.local_python_executor import BASE_BUILTIN_MODULES + + return sorted(set(BASE_BUILTIN_MODULES) | set(SAFE_PYTHON_INTERPRETER_IMPORTS)) + + logger = logging.getLogger(__name__) diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index bc4c26845d..c142990fad 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -297,6 +297,14 @@ def from_dict(cls, data): ) nexent_agents_module.sandbox = sandbox_module +nexent_agent_module = _create_stub_module( + "nexent.core.agents.nexent_agent", + get_local_python_authorized_imports=MagicMock( + return_value=["sdk_default_import"] + ), +) +nexent_agents_module.nexent_agent = nexent_agent_module + class MockProviderCapabilityUnknown(Exception): pass @@ -2045,6 +2053,39 @@ async def test_create_agent_config_routes_memory_policy_through_context_items(se assert "store_memory" in policy assert "instructions" not in mocks["agent_config"].call_args.kwargs + @pytest.mark.asyncio + @pytest.mark.parametrize("sandbox_default_level", ["local", "docker"]) + async def test_create_agent_config_injects_import_policy_only_for_local_executor( + self, + sandbox_default_level, + ): + sdk_authorized_imports = ["sdk_default_import", "sdk_extra_import"] + with patch( + "backend.agents.create_agent_info.os.getenv", + return_value=sandbox_default_level, + ), patch( + "backend.agents.create_agent_info.get_local_python_authorized_imports", + return_value=sdk_authorized_imports, + ) as get_authorized_imports: + mocks = await self._run_context_manager_case( + enable_context_manager=True, + prepared_prompt="", + ) + + expected_authorized_imports = ( + sdk_authorized_imports if sandbox_default_level == "local" else None + ) + assert ( + mocks["build_components"].call_args.kwargs[ + "restricted_python_authorized_imports" + ] + == expected_authorized_imports + ) + if sandbox_default_level == "local": + get_authorized_imports.assert_called_once_with() + else: + get_authorized_imports.assert_not_called() + @pytest.mark.asyncio async def test_create_agent_config_runs_fixed_search_once_without_exposing_tool(self): result_text = "Found 1 relevant memories:\n[1] Existing preference" diff --git a/test/backend/utils/test_context_utils.py b/test/backend/utils/test_context_utils.py index af1e54a75b..b039ca4d28 100644 --- a/test/backend/utils/test_context_utils.py +++ b/test/backend/utils/test_context_utils.py @@ -112,6 +112,29 @@ def test_empty_inputs_emit_only_required_skeleton_and_fallback_items(): assert all(item.type == ContextItemType.SYSTEM for item in items) +@pytest.mark.parametrize("language", ["en", "zh"]) +def test_restricted_python_policy_is_injected_before_code_norms(language): + items = build_context_inputs( + restricted_python_authorized_imports=["json", "csv", "math", "json"], + language=language, + ) + + policy_item = next( + item for item in items if item.id == "system:restricted_python_execution" + ) + policy_text = policy_item.content["text"] + item_ids = [item.id for item in items] + + assert policy_item.type == ContextItemType.SYSTEM + assert policy_item.metadata["authority"] == "platform" + assert policy_item.priority == 25 + assert "`csv`, `json`, `math`" in policy_text + assert "`requests`" in policy_text + assert item_ids.index(policy_item.id) < item_ids.index("system:code_norms") + if language == "en": + assert "### Python Code Execution Boundary" in policy_text + + def test_all_sources_are_naturally_granular_and_keep_stable_order(): items = build_context_inputs( duty="duty", diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index 3ad5408a76..12722bcb3d 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -362,7 +362,8 @@ class _MockToolSign: from sdk.nexent.core.agents import nexent_agent from sdk.nexent.core.agents.nexent_agent import ( NexentAgent, ActionStep, TaskStep, _has_host_tools, _is_retriever_tool, - _build_tool_input, _wrap_tool_with_monitoring, _tool_name + _build_tool_input, _wrap_tool_with_monitoring, _tool_name, + SAFE_PYTHON_INTERPRETER_IMPORTS, get_local_python_authorized_imports, ) from sdk.nexent.core.agents.agent_model import ToolConfig, ModelConfig, AgentConfig, AgentHistory, ExternalA2AAgentConfig @@ -508,6 +509,17 @@ def mock_core_agent(): # ---------------------------------------------------------------------------- +def test_get_local_python_authorized_imports_matches_executor_defaults(monkeypatch): + """The prompt allowlist must mirror the local executor's imports.""" + executor_module = types.ModuleType("smolagents.local_python_executor") + executor_module.BASE_BUILTIN_MODULES = ["json", "queue", "stat"] + monkeypatch.setitem(sys.modules, "smolagents.local_python_executor", executor_module) + + assert get_local_python_authorized_imports() == sorted( + set(SAFE_PYTHON_INTERPRETER_IMPORTS) | {"json", "queue", "stat"} + ) + + def test_type_checking_imports_resolve_context_and_subagent_types(monkeypatch): """Verify type-only imports resolve their expected public symbols.""" context_module = types.ModuleType("sdk.nexent.core.agents.context") From d6501b90a51928d1cfad7acc9379cb4cd5c7d28c Mon Sep 17 00:00:00 2001 From: xuyaqi Date: Mon, 10 Aug 2026 16:29:55 +0800 Subject: [PATCH 005/113] Bugfix: Resolve streaming response caching problem (#3623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复无法正常重命名conversation名称的问题 * 修复天舟北向接口存在流式缓存的问题 --- backend/services/northbound_service.py | 3 ++- test/backend/services/test_northbound_service.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/services/northbound_service.py b/backend/services/northbound_service.py index a8cf3c7ae8..c33b1d50fb 100644 --- a/backend/services/northbound_service.py +++ b/backend/services/northbound_service.py @@ -450,9 +450,10 @@ async def start_streaming_chat( except Exception as e: logger.warning(f"Failed to log token usage: {str(e)}") - # Attach request id header and conversation_id (internal id) + # Attach northbound response headers used by streaming clients and proxies. response.headers["X-Request-Id"] = ctx.request_id response.headers["conversation_id"] = str(conversation_id) + response.headers["X-Accel-Buffering"] = "no" return response diff --git a/test/backend/services/test_northbound_service.py b/test/backend/services/test_northbound_service.py index 4f01ee3ea9..d43782d906 100644 --- a/test/backend/services/test_northbound_service.py +++ b/test/backend/services/test_northbound_service.py @@ -732,6 +732,7 @@ async def _body_iterator(): assert chunks == [b"data: hello\n\n"] assert response.headers["conversation_id"] == "123" assert response.headers["X-Request-Id"] == ctx.request_id + assert response.headers["X-Accel-Buffering"] == "no" assert response.headers["x-existing"] == "1" From 27316bb6ad42a3cdc94a4aca5a936c910a111018 Mon Sep 17 00:00:00 2001 From: xuyaqi Date: Mon, 10 Aug 2026 17:31:12 +0800 Subject: [PATCH 006/113] Bugfix: Fix MCP tool status display showing "enabled" when enabled is false (#3632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复mcp工具的enabled状态为false的时候还显示“已启用 (cherry picked from commit 37fda8806d96d1df557ac20deb34038a9d0e6330) * 修复无法正常创建新会话的问题 --- .../newchat/assistant-ui/threadlist-sidebar.tsx | 10 +++++++++- frontend/app/[locale]/newchat/page.tsx | 17 ++++++++++++++++- .../components/resources/McpList.tsx | 2 +- frontend/types/agentConfig.ts | 1 + 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/frontend/app/[locale]/newchat/assistant-ui/threadlist-sidebar.tsx b/frontend/app/[locale]/newchat/assistant-ui/threadlist-sidebar.tsx index b4170898ad..36cf50193d 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/threadlist-sidebar.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/threadlist-sidebar.tsx @@ -25,10 +25,14 @@ import { useTranslation } from "react-i18next"; interface ThreadListSidebarProps extends SidebarProps { className?: string; generatedTitles?: ReadonlyMap; + onPrepareNewConversation?: () => void; + onNewConversation?: () => void | Promise; } export function ThreadListSidebar({ generatedTitles, + onPrepareNewConversation, + onNewConversation, ...props }: ThreadListSidebarProps) { const { state, toggleSidebar } = useSidebar(); @@ -64,6 +68,7 @@ export function ThreadListSidebar({ variant="ghost" size="icon" className="size-8" + onClick={onNewConversation} > @@ -102,7 +107,10 @@ export function ThreadListSidebar({ >
- + {t("chat.sidebar.newConversation")} diff --git a/frontend/app/[locale]/newchat/page.tsx b/frontend/app/[locale]/newchat/page.tsx index 743ff45895..6d9b1db34b 100644 --- a/frontend/app/[locale]/newchat/page.tsx +++ b/frontend/app/[locale]/newchat/page.tsx @@ -326,6 +326,17 @@ const HomeContent: FC<{ onBack(); }, [onBack]); + const handlePrepareNewConversation = useCallback(() => { + // Do not restore the agent from the thread that is being left. + shouldRestoreAgentRef.current = false; + onBack(); + }, [onBack]); + + const handleNewConversation = useCallback(async () => { + handlePrepareNewConversation(); + await runtime.threads.switchToNewThread(); + }, [handlePrepareNewConversation, runtime]); + const handleAgentSelectedFromLanding = useCallback( async (agent: Agent) => { shouldRestoreAgentRef.current = true; @@ -348,7 +359,11 @@ const HomeContent: FC<{
- +
diff --git a/frontend/app/[locale]/resource-manage/components/resources/McpList.tsx b/frontend/app/[locale]/resource-manage/components/resources/McpList.tsx index 1a431d9692..8d58d600e0 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/McpList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/McpList.tsx @@ -501,7 +501,7 @@ export default function McpList({ tenantId }: { tenantId: string | null }) { key: "enabled", width: "10%", render: (_: any, record: McpServer) => { - const isEnabled = Boolean(record.status); + const isEnabled = record.enabled; return isEnabled ? ( Date: Mon, 10 Aug 2026 17:31:56 +0800 Subject: [PATCH 007/113] fix: restore ASSET_OWNER nav permissions and improve group selection (#3629) - Restore ASSET_OWNER left-nav permissions (/newchat, /agent-tasks, /users) and stop deleting them in v2.4 merged migration - Use getAccessibleGroupIds for knowledge base group pickers so admins can select all tenant groups - Show deleted group placeholders in agent/KB group selects via buildGroupSelectOptions - Bump web/docs Docker images from Node 20 to 22 --- ...store_asset_owner_left_nav_permissions.sql | 27 +++++++++++++ .../sql/migrations/v2.4_merged_migrations.sql | 3 -- .../agentInfo/AgentGenerateDetail.tsx | 30 ++++++++++++--- .../components/document/DocumentList.tsx | 15 +++++--- .../knowledge/KnowledgeBaseEditModal.tsx | 33 ++++++++++------ .../hooks/group/buildGroupSelectOptions.ts | 38 +++++++++++++++++++ frontend/public/locales/en/common.json | 1 + frontend/public/locales/zh/common.json | 1 + 8 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 deploy/sql/migrations/v2.4.1_0807_restore_asset_owner_left_nav_permissions.sql create mode 100644 frontend/hooks/group/buildGroupSelectOptions.ts diff --git a/deploy/sql/migrations/v2.4.1_0807_restore_asset_owner_left_nav_permissions.sql b/deploy/sql/migrations/v2.4.1_0807_restore_asset_owner_left_nav_permissions.sql new file mode 100644 index 0000000000..12e4f3bcda --- /dev/null +++ b/deploy/sql/migrations/v2.4.1_0807_restore_asset_owner_left_nav_permissions.sql @@ -0,0 +1,27 @@ +-- Restore ASSET_OWNER left-nav routes that are missing after earlier migrations: +-- /newchat (1512): inserted by v2.4.0_0721, then removed by v2.4.0_0722 DELETE 1512-1517 +-- /agent-tasks (1513): expected from v2.4.0_0722; ensure present for inconsistent environments +-- /users (1514): omitted when v2.2.2 rewrote LEFT_NAV_MENU, but avatar menu always links here + +BEGIN; + +INSERT INTO nexent.role_permission_t ( + role_permission_id, + user_role, + permission_category, + permission_type, + permission_subtype, + parent_key +) +VALUES + (1512, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/newchat', NULL), + (1513, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-tasks', NULL), + (1514, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/users', NULL) +ON CONFLICT (role_permission_id) DO UPDATE SET + user_role = EXCLUDED.user_role, + permission_category = EXCLUDED.permission_category, + permission_type = EXCLUDED.permission_type, + permission_subtype = EXCLUDED.permission_subtype, + parent_key = EXCLUDED.parent_key; + +COMMIT; diff --git a/deploy/sql/migrations/v2.4_merged_migrations.sql b/deploy/sql/migrations/v2.4_merged_migrations.sql index 297b8b8c9b..fe4044dd16 100644 --- a/deploy/sql/migrations/v2.4_merged_migrations.sql +++ b/deploy/sql/migrations/v2.4_merged_migrations.sql @@ -394,9 +394,6 @@ CREATE INDEX IF NOT EXISTS idx_agent_automation_proposal_owner ON nexent.agent_automation_proposal_t (tenant_id, user_id, status) WHERE delete_flag = 'N'; -DELETE FROM nexent.role_permission_t -WHERE role_permission_id BETWEEN 1512 AND 1517; - -- Keep each permission in the ID range assigned to its role. INSERT INTO nexent.role_permission_t ( role_permission_id, diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx index 3d3b6c686a..0c6fc1e983 100644 --- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx +++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx @@ -40,6 +40,7 @@ import { canManageModels } from "@/lib/auth"; import { USER_ROLES } from "@/const/auth"; import { useConfig } from "@/hooks/useConfig"; import { useGroupList, useGroupDetails } from "@/hooks/group/useGroupList"; +import { buildGroupSelectOptions } from "@/hooks/group/buildGroupSelectOptions"; import { usePromptTemplateList } from "@/hooks/agent/usePromptTemplateList"; import { Can } from "@/components/permission/Can"; import { useAgentConfigStore } from "@/stores/agentConfigStore"; @@ -193,12 +194,29 @@ export default function AgentGenerateDetail({}) { ).sort((a, b) => a - b); }; - const groupSelectOptions = useMemo(() => { - return filteredGroups.map((g) => ({ - label: g.group_name, - value: g.group_id, - })); - }, [filteredGroups]); + const selectedAdvancedGroupIds = Form.useWatch( + "group_ids", + advancedSettingsForm + ); + + const groupSelectOptions = useMemo( + () => + buildGroupSelectOptions({ + groups: filteredGroups, + allGroups, + selectedGroupIds: + selectedAdvancedGroupIds ?? + normalizeNumberArray(editedAgent.group_ids || []), + deletedGroupLabel: t("group.deleted"), + }), + [ + filteredGroups, + allGroups, + selectedAdvancedGroupIds, + editedAgent.group_ids, + t, + ] + ); const selectedMainAgentModel = useMemo(() => { const primaryModelId = editedAgent.model_ids?.[0]; diff --git a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx index d8e4b23b91..b030c0d0b3 100644 --- a/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx +++ b/frontend/app/[locale]/knowledges/components/document/DocumentList.tsx @@ -4,6 +4,7 @@ import React, { forwardRef, useImperativeHandle, useEffect, + useMemo, } from "react"; import { useTranslation } from "react-i18next"; @@ -177,13 +178,17 @@ const DocumentListContainer = forwardRef( const uploadAreaRef = useRef(null); const { state: docState } = useDocumentContext(); const { modelConfig } = useConfig(); - const { user, groupIds } = useAuthorizationContext(); + const { user, getAccessibleGroupIds } = useAuthorizationContext(); const tenantId = user?.tenantId || null; const storageQuota = useStorageQuotaBlocked(tenantId); - // Fetch tenant groups and limit selections to current user's groups. + // Fetch tenant groups and limit selections to accessible groups (all for admin roles). const { data: groupData } = useGroupList(tenantId); - const { groups } = useGroupDetails(groupData?.groups ?? [], groupIds); + const accessibleGroupIds = useMemo( + () => getAccessibleGroupIds(), + [getAccessibleGroupIds] + ); + const { groups } = useGroupDetails(groupData?.groups ?? [], accessibleGroupIds); const groupOptions = groups.map((group) => ({ label: group.group_name, @@ -331,7 +336,7 @@ const DocumentListContainer = forwardRef( const initDefaultGroup = async () => { try { const defaultGroupId = await getTenantDefaultGroupId(tenantId); - if (defaultGroupId && groupIds.includes(defaultGroupId)) { + if (defaultGroupId && accessibleGroupIds.includes(defaultGroupId)) { onSelectedGroupIdsChange([defaultGroupId]); } } catch (error) { @@ -340,7 +345,7 @@ const DocumentListContainer = forwardRef( }; initDefaultGroup(); } - }, [isCreatingMode, tenantId, groupIds, onSelectedGroupIdsChange]); + }, [isCreatingMode, tenantId, accessibleGroupIds, onSelectedGroupIdsChange]); // Clear group IDs when permission is set to PRIVATE React.useEffect(() => { diff --git a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseEditModal.tsx b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseEditModal.tsx index 6cfe201148..d6713d80cc 100644 --- a/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseEditModal.tsx +++ b/frontend/app/[locale]/knowledges/components/knowledge/KnowledgeBaseEditModal.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Modal, Form, Input, Select, message } from "antd"; import { useGroupDetails, useGroupList } from "@/hooks/group/useGroupList"; +import { buildGroupSelectOptions } from "@/hooks/group/buildGroupSelectOptions"; import { Can } from "@/components/permission/Can"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import knowledgeBaseService from "@/services/knowledgeBaseService"; @@ -28,7 +29,7 @@ export function KnowledgeBaseEditModal({ onSuccess, }: KnowledgeBaseEditModalProps) { const { t } = useTranslation("common"); - const { groupIds } = useAuthorizationContext(); + const { getAccessibleGroupIds } = useAuthorizationContext(); const [form] = Form.useForm(); // Name validation state @@ -41,9 +42,23 @@ export function KnowledgeBaseEditModal({ const [currentPermission, setCurrentPermission] = useState("READ_ONLY"); - // Fetch tenant groups and limit selections to current user's groups. + // Fetch tenant groups and limit selections to accessible groups (all for admin roles). const { data: groupData } = useGroupList(tenantId); - const { groups } = useGroupDetails(groupData?.groups ?? [], groupIds); + const accessibleGroupIds = getAccessibleGroupIds(); + const { groups } = useGroupDetails(groupData?.groups ?? [], accessibleGroupIds); + + const selectedGroupIds = Form.useWatch("group_ids", form); + + const groupSelectOptions = useMemo( + () => + buildGroupSelectOptions({ + groups, + allGroups: groupData?.groups ?? [], + selectedGroupIds: selectedGroupIds ?? knowledgeBase?.group_ids ?? [], + deletedGroupLabel: t("group.deleted"), + }), + [groups, groupData?.groups, selectedGroupIds, knowledgeBase?.group_ids, t] + ); // Reset form and states when knowledge base changes React.useEffect(() => { @@ -102,7 +117,7 @@ export function KnowledgeBaseEditModal({ // Ensure group_ids is empty when permission is PRIVATE const groupIds = - values.ingroup_permission === "PRIVATE" ? [] : values.group_ids; + values.ingroup_permission === "PRIVATE" ? [] : values.group_ids ?? []; await knowledgeBaseService.updateKnowledgeBase(knowledgeBase.id, { knowledge_name: values.knowledge_name, @@ -218,13 +233,7 @@ export function KnowledgeBaseEditModal({ ? t("knowledgeBase.create.permission.groupPlaceholder") : t("tenantResources.knowledgeBase.groupNames") } - value={ - isGroupSelectDisabled ? [] : form.getFieldValue("group_ids") - } - options={groups.map((group) => ({ - label: group.group_name, - value: group.group_id, - }))} + options={groupSelectOptions} disabled={isGroupSelectDisabled} /> diff --git a/frontend/hooks/group/buildGroupSelectOptions.ts b/frontend/hooks/group/buildGroupSelectOptions.ts new file mode 100644 index 0000000000..5a2ade85db --- /dev/null +++ b/frontend/hooks/group/buildGroupSelectOptions.ts @@ -0,0 +1,38 @@ +import type { Group } from "@/services/groupService"; + +export interface GroupSelectOption { + label: string; + value: number; +} + +/** + * Build Select options for user groups, including fallback labels for deleted groups + * that are still referenced by the current selection. + */ +export function buildGroupSelectOptions(params: { + groups: Group[]; + allGroups: Group[]; + selectedGroupIds: number[] | undefined; + deletedGroupLabel: string; +}): GroupSelectOption[] { + const { groups, allGroups, selectedGroupIds, deletedGroupLabel } = params; + const existingGroupIds = new Set(allGroups.map((group) => group.group_id)); + const baseOptions = groups.map((group) => ({ + label: group.group_name, + value: group.group_id, + })); + const baseValueSet = new Set(baseOptions.map((option) => option.value)); + const orphanOptions = (selectedGroupIds ?? []) + .filter( + (id): id is number => + typeof id === "number" && + !existingGroupIds.has(id) && + !baseValueSet.has(id) + ) + .map((id) => ({ + label: deletedGroupLabel, + value: id, + })); + + return [...baseOptions, ...orphanOptions]; +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0e6de5c5d0..f87b20fc56 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -892,6 +892,7 @@ "knowledgeBase.name.new": "new_base", "knowledgeBase.message.getDocumentsFailed": "Failed to get documents", "knowledgeBase.create.permission.groupPlaceholder": "No user group", + "group.deleted": "Deleted user group", "knowledgeBase.create.preserveSourceFile": "Preserve document copy", "knowledgeBase.ingroup.permission.EDIT": "In Group Read/Write", "knowledgeBase.ingroup.permission.READ_ONLY": "In Group Read Only", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index ed29bca9ef..d0c476cbe2 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -860,6 +860,7 @@ "knowledgeBase.name.new": "新知识库", "knowledgeBase.message.getDocumentsFailed": "获取文档列表失败", "knowledgeBase.create.permission.groupPlaceholder": "无所属用户组", + "group.deleted": "已删除的用户组", "knowledgeBase.create.preserveSourceFile": "保留文档副本", "knowledgeBase.ingroup.permission.EDIT": "同组可编辑", "knowledgeBase.ingroup.permission.READ_ONLY": "同组只读", From e443892fa20cfedd812da123d3ae06d6af7fa8ba Mon Sep 17 00:00:00 2001 From: Xia Yichen Date: Tue, 11 Aug 2026 11:49:12 +0800 Subject: [PATCH 008/113] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20NL2Ski?= =?UTF-8?q?lls=20using=20new=20thread=20UI=20(#3631)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ Refactor: NL2Skills using new thread UI ✨ NL2Skills now supports @Mention revision * 🧪 Add test files * 🧪 Add test files --- backend/agents/nl2skill_agent.py | 25 + backend/agents/skill_creation_agent.py | 122 --- backend/apps/skill_app.py | 120 +-- backend/consts/model.py | 22 +- .../prompts/skill_creation_complicate_en.yaml | 37 +- .../prompts/skill_creation_complicate_zh.yaml | 37 +- backend/prompts/skill_creation_simple_en.yaml | 37 +- backend/prompts/skill_creation_simple_zh.yaml | 37 +- backend/services/nl2skill_service.py | 318 ++++++++ backend/services/skill_service.py | 293 ------- backend/utils/content_classifier_utils.py | 104 ++- backend/utils/prompt_template_utils.py | 20 +- .../agentConfig/SkillBuildModal.tsx | 723 ++++-------------- .../agentConfig/SkillCodePreview.tsx | 144 ++++ .../agentConfig/SkillDraftPanel.tsx | 198 +++-- .../agentConfig/skillFileLanguage.ts | 172 +++++ .../agentConfig/skillPreviewPolicy.ts | 58 ++ .../adapter/remote-chat-model-adapter.ts | 200 ++++- .../[locale]/newchat/assistant-ui/chat.tsx | 16 +- .../newchat/assistant-ui/composer.tsx | 259 ++++--- .../assistant-ui/nl2skill-chat-panel.tsx | 97 +++ .../[locale]/newchat/assistant-ui/thread.tsx | 434 ++++++++--- .../[locale]/newchat/ui/directive-text.tsx | 76 +- .../[locale]/newchat/ui/skill-directives.ts | 280 +++++++ .../[locale]/newchat/ui/skill-file-card.tsx | 103 +++ .../newchat/ui/skill-file-mention.tsx | 129 ++++ .../components/common/markdownRenderer.tsx | 58 +- frontend/package.json | 3 + frontend/public/locales/en/common.json | 3 + frontend/public/locales/zh/common.json | 3 + frontend/server.js | 43 +- frontend/services/api.ts | 14 +- frontend/services/conversationService.ts | 40 +- frontend/services/skillService.ts | 251 ------ test/backend/app/test_skill_app.py | 231 +++--- .../backend/services/test_nl2skill_service.py | 424 ++++++++++ test/backend/services/test_skill_service.py | 77 -- .../utils/test_content_classifier_utils.py | 246 +++++- .../utils/test_prompt_template_utils.py | 33 +- 39 files changed, 3550 insertions(+), 1937 deletions(-) create mode 100644 backend/agents/nl2skill_agent.py delete mode 100644 backend/agents/skill_creation_agent.py create mode 100644 backend/services/nl2skill_service.py create mode 100644 frontend/app/[locale]/agents/components/agentConfig/SkillCodePreview.tsx create mode 100644 frontend/app/[locale]/agents/components/agentConfig/skillFileLanguage.ts create mode 100644 frontend/app/[locale]/agents/components/agentConfig/skillPreviewPolicy.ts create mode 100644 frontend/app/[locale]/newchat/assistant-ui/nl2skill-chat-panel.tsx create mode 100644 frontend/app/[locale]/newchat/ui/skill-directives.ts create mode 100644 frontend/app/[locale]/newchat/ui/skill-file-card.tsx create mode 100644 frontend/app/[locale]/newchat/ui/skill-file-mention.tsx create mode 100644 test/backend/services/test_nl2skill_service.py diff --git a/backend/agents/nl2skill_agent.py b/backend/agents/nl2skill_agent.py new file mode 100644 index 0000000000..cbb4d6cecb --- /dev/null +++ b/backend/agents/nl2skill_agent.py @@ -0,0 +1,25 @@ +"""Build the ephemeral NL2Skill agent configuration.""" + +from nexent.core.agents.agent_model import AgentConfig + + +NL2SKILL_NAME = "__skill_creator__" + + +def create_nl2skill_agent_config( + system_prompt: str, + model_name: str, +) -> AgentConfig: + """Create one request-scoped skill creator without persistent state.""" + + return AgentConfig( + name=NL2SKILL_NAME, + description="Ephemeral natural-language skill builder", + prompt_templates=None, + tools=[], + max_steps=5, + model_name=model_name, + provide_run_summary=False, + instructions=system_prompt, + enable_planning=False, + ) diff --git a/backend/agents/skill_creation_agent.py b/backend/agents/skill_creation_agent.py deleted file mode 100644 index 37c3ec2ad8..0000000000 --- a/backend/agents/skill_creation_agent.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Skill creation agent module for interactive skill generation.""" - -import logging -import threading -from typing import List - -from nexent.core.agents.agent_model import AgentConfig, AgentRunInfo, ModelConfig, ToolConfig -from nexent.core.agents.run_agent import agent_run_thread -from nexent.core.utils.observer import MessageObserver - -logger = logging.getLogger("skill_creation_agent") - - -def create_skill_creation_agent_config( - system_prompt: str, - model_config_list: List[ModelConfig], - local_skills_dir: str = "" -) -> AgentConfig: - """ - Create agent config for skill creation with builtin tools. - - Args: - system_prompt: Custom system prompt to replace smolagent defaults - model_config_list: List of model configurations - - Returns: - AgentConfig configured for skill creation - """ - if not model_config_list: - raise ValueError("model_config_list cannot be empty") - - first_model = model_config_list[0] - - prompt_templates = { - "system_prompt": system_prompt, - "managed_agent": { - "task": "{task}", - "report": "## {name} Report\n\n{final_answer}" - }, - "planning": { - "initial_plan": "", - "update_plan_pre_messages": "", - "update_plan_post_messages": "" - }, - "final_answer": { - "pre_messages": "", - "post_messages": "" - } - } - - return AgentConfig( - name="__skill_creator__", - description="Internal skill creator agent", - prompt_templates=prompt_templates, - tools=[], - max_steps=5, - model_name=first_model.cite_name - ) - - -def run_skill_creation_agent( - query: str, - agent_config: AgentConfig, - model_config_list: List[ModelConfig], - observer: MessageObserver, - stop_event: threading.Event, -) -> None: - """ - Run the skill creator agent synchronously. - - Args: - query: User query for the agent - agent_config: Pre-configured agent config - model_config_list: List of model configurations - observer: Message observer for capturing agent output - stop_event: Threading event for cancellation - """ - agent_run_info = AgentRunInfo( - query=query, - model_config_list=model_config_list, - observer=observer, - agent_config=agent_config, - stop_event=stop_event - ) - - agent_run_thread(agent_run_info) - - -def create_skill_from_request( - system_prompt: str, - user_prompt: str, - model_config_list: List[ModelConfig], - observer: MessageObserver, - stop_event: threading.Event, - local_skills_dir: str = "" -) -> None: - """ - Run skill creation agent to create a skill interactively. - - The agent will write the skill content to tmp.md in local_skills_dir. - Frontend should read tmp.md after agent completes to get the skill content. - - Args: - system_prompt: System prompt with skill creation instructions - user_prompt: User's skill description request - model_config_list: List of model configurations - observer: Message observer for capturing agent output - stop_event: Threading event for cancellation - local_skills_dir: Path to local skills directory for file operations - """ - agent_config = create_skill_creation_agent_config( - system_prompt=system_prompt, - model_config_list=model_config_list, - local_skills_dir=local_skills_dir - ) - - thread_agent = threading.Thread( - target=run_skill_creation_agent, - args=(user_prompt, agent_config, model_config_list, observer, stop_event) - ) - thread_agent.start() - thread_agent.join() diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py index 928357fb6a..afd81211c9 100644 --- a/backend/apps/skill_app.py +++ b/backend/apps/skill_app.py @@ -1,27 +1,31 @@ """Skill management HTTP endpoints.""" -from nexent.core.agents.agent_model import ModelConfig import logging +from http import HTTPStatus from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Form, Header -from starlette.responses import JSONResponse, StreamingResponse -from http import HTTPStatus +from fastapi import APIRouter, File, Form, Header, HTTPException, Query, UploadFile from pydantic import BaseModel, Field +from starlette.responses import JSONResponse, StreamingResponse -from consts.const import APP_VERSION, STREAMABLE_CONTENT_TYPES +from consts.const import APP_VERSION from consts.exceptions import ForbiddenError, SkillException, UnauthorizedError +from consts.model import ( + NL2SkillRunRequest, + SkillCreateRequest, + SkillInstanceInfoRequest, + SkillResponse, + SkillUpdateRequest, +) +from services.asset_owner_visibility import can_view_skill +from services.nl2skill_service import create_nl2skill_stream from services.skill_service import ( SkillService, - skill_creation_task_manager, - stream_skill_creation, - update_skill_list, get_official_skills_with_status, install_skills_from_zip_for_tenant, + update_skill_list, ) -from consts.model import SkillInstanceInfoRequest, SkillCreateRequest, SkillCreateInteractiveRequest, SkillUpdateRequest, SkillResponse from utils.auth_utils import get_current_user_id, get_current_user_info -from services.asset_owner_visibility import can_view_skill ASSET_OWNER_SKILL_VIEW_DENIED = {"content": "您无权限查看"} @@ -693,93 +697,27 @@ async def delete_skill( raise HTTPException(status_code=500, detail="Internal server error") -def _build_model_config_from_tenant(tenant_id: str) -> ModelConfig: - """Build ModelConfig from tenant's quick-config LLM model.""" - from utils.config_utils import tenant_config_manager, get_model_name_from_config - from consts.const import MODEL_CONFIG_MAPPING - from nexent.core.models.prompt_cache import resolve_prompt_cache_profile - - quick_config = tenant_config_manager.get_model_config( - key=MODEL_CONFIG_MAPPING["llm"], - tenant_id=tenant_id - ) - if not quick_config: - raise ValueError("No LLM model configured for tenant") - - model_factory = quick_config.get("model_factory") - return ModelConfig( - cite_name=quick_config.get("display_name", "default"), - api_key=quick_config.get("api_key", ""), - model_name=get_model_name_from_config(quick_config), - url=quick_config.get("base_url", ""), - temperature=0.1, - top_p=0.95, - ssl_verify=quick_config.get("ssl_verify", False), - model_factory=model_factory, - prompt_cache=resolve_prompt_cache_profile(model_factory), - ) - - -@skill_creator_router.post("/create") -async def create_skill( - request: SkillCreateInteractiveRequest, +@skill_creator_router.post("/nl2skill/run") +async def nl2skill_run_api( + request: NL2SkillRunRequest, authorization: Optional[str] = Header(None) ): - """Create a skill interactively via LLM agent. - - Loads the skill creation prompt template (simple or complicated based on complexity), - runs an internal agent with WriteSkillFileTool and ReadSkillMdTool, extracts the skill content - from the final answer, and streams step progress and token content via SSE. - - Yields SSE events: - - step_count: Current agent step number - - skill_content: Token-level content (thinking, code, deep_thinking, tool output) - - final_answer: Complete skill content with and delimiters - - done: Stream completion signal - """ + """Run one non-persistent, multi-turn NL2Skill conversation turn.""" try: _, tenant_id, user_language = get_current_user_info(authorization) except Exception as e: logger.error(f"Unauthorized access attempt: {e}") raise HTTPException(status_code=401, detail="Unauthorized") - # Build model config from tenant - model_config = _build_model_config_from_tenant(tenant_id) - - # Get language from request or user preference - lang = request.language or user_language or "zh" - - # Delegate to service layer - task_id, generator = stream_skill_creation( - user_request=request.user_request, - language=lang, - model_config=model_config, - existing_skill=request.existing_skill, - complexity=request.complexity or "simple" - ) - - return StreamingResponse(generator(), media_type="text/event-stream", headers={"X-Task-ID": task_id}) - - -@skill_creator_router.get("/stop/{task_id}") -async def stop_skill_creation( - task_id: str, - authorization: Optional[str] = Header(None) -): - """Stop an active skill creation task. - - Args: - task_id: The task ID returned from the /create endpoint (passed via X-Task-ID header) - """ try: - _, _ = get_current_user_id(authorization) - except Exception as e: - logger.error(f"Unauthorized access attempt: {e}") - raise HTTPException(status_code=401, detail="Unauthorized") - - success = skill_creation_task_manager.stop_task(task_id) - - if success: - return JSONResponse(content={"status": "success", "message": "Skill creation task stopped"}) - else: - return JSONResponse(content={"status": "not_found", "message": "Task not found or already completed"}, status_code=404) + stream = await create_nl2skill_stream( + request=request, + tenant_id=tenant_id, + language=request.language or user_language or "zh", + ) + return StreamingResponse(stream, media_type="text/event-stream") + except HTTPException: + raise + except Exception: + logger.exception("NL2Skill run error") + raise HTTPException(status_code=500, detail="NL2Skill run error.") diff --git a/backend/consts/model.py b/backend/consts/model.py index 650537bd4d..dca8bc1387 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -362,6 +362,20 @@ class NL2AgentRunRequest(BaseModel): minio_files: Optional[List[Dict[str, Any]]] = None +class NL2SkillRunRequest(BaseModel): + """Request payload for one ephemeral NL2Skill conversation turn.""" + + query: str = Field(min_length=1) + history: Optional[List[HistoryItem]] = None + draft_snapshot: Optional[Dict[str, Any]] = None + complexity: Literal["simple", "complicated"] = "complicated" + language: Optional[Literal["zh", "en"]] = None + model_id: Optional[int] = Field( + default=None, + description="Optional model ID override. When not specified, uses the tenant's configured LLM model.", + ) + + class MessageUnit(BaseModel): type: str content: str @@ -1437,14 +1451,6 @@ class SkillResponse(BaseModel): update_time: Optional[str] = None -class SkillCreateInteractiveRequest(BaseModel): - """Request model for interactive skill creation via LLM agent.""" - user_request: str - existing_skill: Optional[Dict[str, Any]] = None - complexity: Optional[str] = "simple" - language: Optional[str] = "zh" - - # --------------------------------------------------------------------------- # MCP Management Data Models # --------------------------------------------------------------------------- diff --git a/backend/prompts/skill_creation_complicate_en.yaml b/backend/prompts/skill_creation_complicate_en.yaml index c4f9c3f4d1..05eb90ab95 100644 --- a/backend/prompts/skill_creation_complicate_en.yaml +++ b/backend/prompts/skill_creation_complicate_en.yaml @@ -1,9 +1,26 @@ system_prompt: |- You are a professional skill creation assistant that helps users create or modify skill Markdown files, supporting both single-file and multi-file scenarios. + ## Multi-turn conversation + + - If essential information is missing, ask one concise clarification question and do not emit XML control blocks 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(', ') }}. + - Output exactly one complete `...` block for each targeted file, followed by ``. + - Do not output `` and do not create, rename, delete, or modify any non-targeted file. + - Treat the mention tags in the user request as file selectors, not as content to insert into a file. + {% else %} + - When generating or modifying a skill, output the complete latest snapshot rather than a partial patch. + - 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. + {% endif %} + A skill consists of multiple files, including: core description file (SKILL.md), example documents, script code, and more. - {% if existing_skill %} + {% if has_existing_skill_content %} ## Modifying Existing Skill Mode The user is modifying an existing skill. Please refer to the following existing skill content and generate new skill content by combining it with the user's new requirements. @@ -43,7 +60,6 @@ system_prompt: |- ### Single-File Scenario (SKILL.md Only) - ``` --- name: your-skill-name @@ -61,11 +77,9 @@ 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 @@ -91,7 +105,6 @@ system_prompt: |- Your friendly message to the user, such as skill created, feature highlights, etc. - ``` ### File Reference Declaration Rules (Important) @@ -185,7 +198,18 @@ system_prompt: |- - **Do not** include specific content from referenced files in SKILL.md; use reference tags instead. user_prompt: |- - {% if existing_skill %} + {% if target_files %} + Modify only the existing files listed below according to the user's request. + + Target files: {{ target_files | join(', ') }} + + User request: + + {{ user_request }} + + Return complete replacement content only for the target files, then a concise summary. Do not output or modify any other file. + {% else %} + {% if has_existing_skill_content %} Please help me modify the existing skill "{{ existing_skill.name }}", with the following requirements: {{ user_request }} @@ -222,3 +246,4 @@ user_prompt: |- **Step 3**: Generate a concise summary as the final response (including skill name, feature highlights, applicable scenarios, created file list) Please ensure all steps are completed! + {% endif %} diff --git a/backend/prompts/skill_creation_complicate_zh.yaml b/backend/prompts/skill_creation_complicate_zh.yaml index d91f1c58e6..3bd971ce83 100644 --- a/backend/prompts/skill_creation_complicate_zh.yaml +++ b/backend/prompts/skill_creation_complicate_zh.yaml @@ -1,9 +1,26 @@ system_prompt: |- 你是一个专业的技能创建助手,用于帮助用户创建或修改技能 Markdown 文件,支持单文件和多文件场景。 + ## 多轮对话规则 + + - 如果需求缺少关键信息,先提出一个简洁的澄清问题;该轮不要输出 XML 控制块。 + - 修改技能时同时参考对话历史和当前技能快照。 + {% if target_files %} + - 本轮是定向文件修改。只能修改这些文件:{{ target_files | join(', ') }}。 + - 每个目标文件必须且只能输出一个完整的 `...` 块,随后输出 ``。 + - 不要输出 ``,不得创建、重命名、删除或修改任何非目标文件。 + - 用户请求中的 Mention 标签只是文件选择器,不要把标签本身写入文件内容。 + {% else %} + - 一旦生成或修改技能,必须输出最新的完整快照,不要只输出局部补丁。 + - 输出顺序固定为 ``、零个或多个 ``、``。 + - 所有 XML 控制标签必须独占一行,控制块外不要包裹 Markdown 代码围栏。 + - 不要在澄清、思考或总结文本中引用或解释 XML 控制标签;它们只能作为真实结构独占一行输出。 + - 输出结构时直接从 `` 开始,不要添加 Markdown 代码围栏或语言标识。 + {% endif %} + 技能由多个文件组成,包括:核心描述文件(SKILL.md)、示例文档、脚本代码等。 - {% if existing_skill %} + {% if has_existing_skill_content %} ## 修改存量技能模式 用户正在修改存量技能,请参考以下存量技能内容,并结合用户的新需求,综合生成新的技能内容。 @@ -43,7 +60,6 @@ system_prompt: |- ### 单文件场景(仅需要 SKILL.md) - ``` --- name: your-skill-name @@ -61,11 +77,9 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 - ``` ### 多文件场景(需要 SKILL.md + 其他文件) - ``` --- name: your-skill-name @@ -95,7 +109,6 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 - ``` ### 文件引用声明规则(重要) @@ -189,7 +202,18 @@ system_prompt: |- - **不要**在 SKILL.md 中包含引用文件的具体内容,应使用引用标签代替。 user_prompt: |- - {% if existing_skill %} + {% if target_files %} + 请仅根据用户请求修改下面列出的已有文件。 + + 目标文件:{{ target_files | join(', ') }} + + 用户请求: + + {{ user_request }} + + 只返回目标文件的完整替换内容,随后给出简洁总结。不要输出或修改其他文件。 + {% else %} + {% if has_existing_skill_content %} 请帮我修改存量技能「{{ existing_skill.name }}」,需求如下: {{ user_request }} @@ -226,3 +250,4 @@ user_prompt: |- **步骤 3**:生成简洁的总结作为最终回答(包括技能名称、功能亮点、适用场景、创建的文件列表) 请确保所有步骤都执行完成! + {% endif %} diff --git a/backend/prompts/skill_creation_simple_en.yaml b/backend/prompts/skill_creation_simple_en.yaml index 956f797b52..d9949faf63 100644 --- a/backend/prompts/skill_creation_simple_en.yaml +++ b/backend/prompts/skill_creation_simple_en.yaml @@ -1,7 +1,25 @@ system_prompt: |- You are a professional skill creation assistant that helps users create or modify simple skill Markdown documentation files, including: skill name, description, tags, prompt instructions, etc. - {% if existing_skill %} + ## Multi-turn conversation + + - If essential information is missing, ask one concise clarification question and do not emit XML control blocks 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(', ') }}. + - Output exactly one complete `...` block for each targeted file, followed by ``. + - Do not output `` and do not create, rename, delete, or modify any non-targeted file. + - Treat the mention tags in the user request as file selectors, not as content to insert into a file. + {% else %} + - 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. + - 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 %} + + {% if has_existing_skill_content %} ## Modifying Existing Skill Mode The user is modifying an existing skill. Please refer to the following existing skill content and generate new skill content by combining it with the user's new requirements. @@ -33,11 +51,11 @@ system_prompt: |- ## Output Format **Important**: All content that needs to be written to SKILL.md must be wrapped with `` and `` XML delimiters. + `` is mandatory: immediately after the final SKILL.md character, emit `` on its own line before producing anything else. Never omit it. Summary content must be wrapped with `` and `` XML delimiters. ### Format Example - ``` --- name: your-skill-name @@ -55,7 +73,6 @@ system_prompt: |- Your friendly message to the user, such as skill created, feature highlights, etc. - ``` ## Writing Descriptions (Key Point) @@ -74,7 +91,18 @@ system_prompt: |- - **Do not** use Windows-style backslashes in paths. user_prompt: |- - {% if existing_skill %} + {% if target_files %} + Modify only the existing files listed below according to the user's request. + + Target files: {{ target_files | join(', ') }} + + User request: + + {{ user_request }} + + Return complete replacement content only for the target files, then a concise summary. Do not output or modify any other file. + {% else %} + {% if has_existing_skill_content %} Please help me modify the existing skill "{{ existing_skill.name }}", with the following requirements: {{ user_request }} @@ -101,3 +129,4 @@ user_prompt: |- **Step 2**: Generate a concise summary as the final response (including skill name, feature highlights, applicable scenarios) Please ensure both steps are completed! + {% endif %} diff --git a/backend/prompts/skill_creation_simple_zh.yaml b/backend/prompts/skill_creation_simple_zh.yaml index b8960a6af9..c08534f1f0 100644 --- a/backend/prompts/skill_creation_simple_zh.yaml +++ b/backend/prompts/skill_creation_simple_zh.yaml @@ -1,7 +1,25 @@ system_prompt: |- 你是一个专业的技能创建助手,用于帮助用户创建或修改简单的技能 Markdown 说明文件,内容包括:技能名称、技能描述、技能标签、技能提示词等。 - {% if existing_skill %} + ## 多轮对话规则 + + - 如果需求缺少关键信息,先提出一个简洁的澄清问题;该轮不要输出 XML 控制块。 + - 修改技能时同时参考对话历史和当前技能快照。 + {% if target_files %} + - 本轮是定向文件修改。只能修改这些文件:{{ target_files | join(', ') }}。 + - 每个目标文件必须且只能输出一个完整的 `...` 块,随后输出 ``。 + - 不要输出 ``,不得创建、重命名、删除或修改任何非目标文件。 + - 用户请求中的 Mention 标签只是文件选择器,不要把标签本身写入文件内容。 + {% else %} + - 一旦生成或修改技能,必须输出最新的完整快照,不要只输出局部补丁。 + - 所有 XML 控制标签必须独占一行,控制块外不要包裹 Markdown 代码围栏。 + - 不要在澄清、思考或总结文本中引用或解释 XML 控制标签;它们只能作为真实结构独占一行输出。 + - 输出结构时直接从 `` 开始,不要添加 Markdown 代码围栏或语言标识。 + - `` 块一旦开始,就不得在输出 `` 前结束响应或切换到 ``;`` 必须独占一行。 + - 输出结束前执行结构自检:必须恰好包含一个 `` 和一个与之配对的 ``,且 `` 必须位于 `` 之前。 + {% endif %} + + {% if has_existing_skill_content %} ## 修改存量技能模式 用户正在修改存量技能,请参考以下存量技能内容,并结合用户的新需求,综合生成新的技能内容。 @@ -33,11 +51,11 @@ system_prompt: |- ## 输出格式 **重要**:所有需要写入 SKILL.md 的内容必须用 `` 和 `` XML 分隔符包裹。 + `` 是强制闭合标签:写完 SKILL.md 的最后一个字符后,下一步必须先独占一行输出 ``,绝不能省略。 总结说明必须用 `` 和 `` XML 分隔符包裹。 ### 格式示例 - ``` --- name: your-skill-name @@ -55,7 +73,6 @@ system_prompt: |- 这里是你对用户的友好说明,如技能已创建、功能亮点等 - ``` ## 编写描述(关键) @@ -74,7 +91,18 @@ system_prompt: |- - **不要**在路径中使用 Windows 风格的反斜杠。 user_prompt: |- - {% if existing_skill %} + {% if target_files %} + 请仅根据用户请求修改下面列出的已有文件。 + + 目标文件:{{ target_files | join(', ') }} + + 用户请求: + + {{ user_request }} + + 只返回目标文件的完整替换内容,随后给出简洁总结。不要输出或修改其他文件。 + {% else %} + {% if has_existing_skill_content %} 请帮我修改存量技能「{{ existing_skill.name }}」,需求如下: {{ user_request }} @@ -101,3 +129,4 @@ user_prompt: |- **步骤 2**:生成简洁的总结作为最终回答(包括技能名称、功能亮点、适用场景) 请确保两个步骤都执行完成! + {% endif %} diff --git a/backend/services/nl2skill_service.py b/backend/services/nl2skill_service.py new file mode 100644 index 0000000000..c122f5aa9d --- /dev/null +++ b/backend/services/nl2skill_service.py @@ -0,0 +1,318 @@ +"""Business logic for the ephemeral NL2Skill runtime.""" + +import asyncio +import json +import logging +import re +import threading +from collections.abc import AsyncIterator +from typing import Any + +from nexent.core.agents.agent_model import AgentHistory, AgentRunInfo +from nexent.core.agents.run_agent import agent_run +from nexent.core.utils.observer import MessageObserver + +from agents.create_agent_info import create_model_config_list +from agents.nl2skill_agent import create_nl2skill_agent_config +from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING +from consts.model import HistoryItem, NL2SkillRunRequest +from database.model_management_db import get_model_by_model_id +from utils.config_utils import tenant_config_manager, get_model_name_from_config +from utils.content_classifier_utils import ContentClassifier +from utils.prompt_template_utils import get_skill_creation_simple_prompt_template + +logger = logging.getLogger(__name__) + +PARSABLE_MODEL_TYPES = frozenset( + { + "model_output", + "model_output_thinking", + "model_output_deep_thinking", + "model_output_code", + "model_thinking_output", + } +) + +SKILL_FILE_DIRECTIVE_PATTERN = re.compile( + r'<(?:reference|use_script)\b[^>]*\bpath\s*=\s*(["\'])(.*?)\1[^>]*/\s*>', + re.IGNORECASE, +) + + +def _normalize_relative_path(value: str) -> str | None: + path = value.strip().replace("\\", "/") + if not path or "\x00" in path or path.startswith("/"): + return None + if re.match(r"^[A-Za-z]:/", path): + return None + parts = path.split("/") + if any(not part or part == ".." for part in parts): + return None + return "/".join(part for part in parts if part != ".") + + +def _extract_target_files( + query: str, + draft_snapshot: dict[str, Any] | None, +) -> list[str]: + if not draft_snapshot or not isinstance(draft_snapshot.get("files"), list): + return [] + + available_paths = { + normalized + for file in draft_snapshot["files"] + if isinstance(file, dict) + and (normalized := _normalize_relative_path(str(file.get("path") or ""))) + } + targets: list[str] = [] + for match in SKILL_FILE_DIRECTIVE_PATTERN.finditer(query): + path = _normalize_relative_path(match.group(2)) + if path in available_paths and path not in targets: + targets.append(path) + return targets + + +def _convert_history(history: list[HistoryItem] | None) -> list[AgentHistory]: + return [ + AgentHistory(role=item.role, content=item.content) + for item in history or [] + if item.role in {"user", "assistant"} + ] + + +def _assemble_draft_content(draft_snapshot: dict[str, Any]) -> str: + content = str(draft_snapshot.get("content") or "") + files = draft_snapshot.get("files") + if not isinstance(files, list): + return content + + parts: list[str] = [] + skill_content = content + for file in files: + if not isinstance(file, dict): + continue + path = str(file.get("path") or "").strip() + file_content = str(file.get("content") or "") + if path == "SKILL.md": + skill_content = file_content + elif path and file_content.strip(): + parts.append(f'\n{file_content}\n') + + if not skill_content.strip() and not parts: + return "" + + return "\n\n".join([f"\n{skill_content}\n", *parts]) + + +def _normalize_draft_snapshot( + draft_snapshot: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not draft_snapshot: + return None + return { + "name": str(draft_snapshot.get("name") or ""), + "description": str(draft_snapshot.get("description") or ""), + "tags": draft_snapshot.get("tags") + if isinstance(draft_snapshot.get("tags"), list) + else [], + "content": _assemble_draft_content(draft_snapshot), + } + + +def _resolve_model_for_nl2skill( + tenant_id: str, + model_id: int | None, + model_config_list: list, +) -> tuple[str, str, dict]: + """Resolve the model configuration for NL2Skill. + + Args: + tenant_id: Current tenant ID + model_id: Optional model ID override from request + model_config_list: Full model config list for the tenant + + Returns: + Tuple of (cite_name, model_name, model_config) for the resolved model + + Raises: + ValueError: When no valid model is found + """ + if model_id is not None: + # Use the explicitly requested model + model_info = get_model_by_model_id(model_id, tenant_id) + if model_info: + return ( + model_info["display_name"], + model_info["display_name"], + model_info, + ) + raise ValueError(f"Requested model_id {model_id} not found for tenant") + + # Use the tenant-configured LLM model (MODEL_CONFIG_MAPPING["llm"]) + llm_key = MODEL_CONFIG_MAPPING["llm"] + llm_config = tenant_config_manager.get_model_config( + key=llm_key, tenant_id=tenant_id + ) + if llm_config: + # Check if there's a matching model in model_config_list with cite_name "main_model" + for config in model_config_list: + if config.cite_name == "main_model": + return ("main_model", config.model_name, llm_config) + # Fallback: construct from config + model_name = get_model_name_from_config(llm_config) if llm_config.get( + "model_name") else "" + if model_name: + return ("main_model", model_name, llm_config) + + # Final fallback: use first model in list + if model_config_list: + first = model_config_list[0] + return (first.cite_name, first.model_name, {}) + + raise ValueError("No LLM model configured for tenant") + + +async def build_nl2skill_run_info( + request: NL2SkillRunRequest, + tenant_id: str, + language: str, +) -> AgentRunInfo: + """Build all request-scoped objects for one NL2Skill turn.""" + + template_language = LANGUAGE["EN"] if language == LANGUAGE["EN"] else LANGUAGE["ZH"] + target_files = _extract_target_files(request.query, request.draft_snapshot) + draft_snapshot = _normalize_draft_snapshot(request.draft_snapshot) + template = get_skill_creation_simple_prompt_template( + language=template_language, + existing_skill=draft_snapshot, + complexity=request.complexity, + user_request=request.query, + target_files=target_files, + ) + model_config_list = await create_model_config_list(tenant_id) + if not model_config_list: + raise ValueError("No LLM model configured for tenant") + + # Resolve model: use request.model_id if provided, otherwise use tenant-configured model + cite_name, model_name, model_info = _resolve_model_for_nl2skill( + tenant_id=tenant_id, + model_id=request.model_id, + model_config_list=model_config_list, + ) + + return AgentRunInfo( + query=template.get("user_prompt") or request.query, + model_config_list=model_config_list, + observer=MessageObserver(lang=template_language), + agent_config=create_nl2skill_agent_config( + system_prompt=template.get("system_prompt", ""), + model_name=model_name, + ), + history=_convert_history(request.history), + stop_event=threading.Event(), + enable_planning=False, + sandbox_config=None, + redis_client=None, + ) + + +def _decorate_event( + event: dict[str, Any], + sequence: int, +) -> dict[str, Any]: + event = {**event, "sequence": sequence} + event_type = event.get("type") + if event_type == "skill_body": + event["block_id"] = "skill:SKILL.md" + elif event_type == "file_content": + event["block_id"] = f"file:{event.get('path', '')}" + elif event_type == "summary": + event["block_id"] = "summary" + return event + + +async def create_nl2skill_stream( + request: NL2SkillRunRequest, + tenant_id: str, + language: str, +) -> AsyncIterator[str]: + """Create the SSE payload stream for one ephemeral NL2Skill turn.""" + + run_info = await build_nl2skill_run_info(request, tenant_id, language) + target_files = _extract_target_files(request.query, request.draft_snapshot) + target_file_set = set(target_files) + + async def generate() -> AsyncIterator[str]: + classifier = ContentClassifier() + sequence = 0 + + def serialize(event: dict[str, Any]) -> str: + nonlocal sequence + sequence += 1 + payload = _decorate_event(event, sequence) + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + def is_allowed_write(event: dict[str, Any]) -> bool: + if not target_file_set: + return True + if event.get("type") == "skill_body": + return "SKILL.md" in target_file_set + if event.get("type") == "file_content": + return event.get("path") in target_file_set + return True + + try: + if target_files: + yield serialize( + { + "type": "target_files", + "content": json.dumps(target_files, ensure_ascii=False), + "paths": target_files, + } + ) + async for raw_chunk in agent_run(run_info): + try: + chunk = json.loads(raw_chunk) if isinstance(raw_chunk, str) else raw_chunk + except json.JSONDecodeError: + logger.warning("Ignoring malformed NL2Skill observer chunk") + continue + if not isinstance(chunk, dict): + continue + + chunk_type = str(chunk.get("type") or "") + content = str(chunk.get("content") or "") + if chunk_type in PARSABLE_MODEL_TYPES: + for event in classifier.classify(content, origin_type=chunk_type): + if is_allowed_write(event): + yield serialize(event) + continue + + if chunk_type == "final_answer" and classifier.saw_control_tag: + continue + + if chunk_type == "final_answer" and "<" in content: + for event in classifier.classify(content, origin_type=chunk_type): + if is_allowed_write(event): + yield serialize(event) + continue + + yield serialize(chunk) + + for event in classifier.flush(): + if is_allowed_write(event): + yield serialize(event) + yield serialize({"type": "done", "content": ""}) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("NL2Skill execution failed") + yield serialize( + { + "type": "error", + "content": "NL2Skill execution failed.", + } + ) + finally: + run_info.stop_event.set() + + return generate() diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py index 13c866f0ca..a18a82cb99 100644 --- a/backend/services/skill_service.py +++ b/backend/services/skill_service.py @@ -3,25 +3,20 @@ import aiofiles import argparse import ast -import asyncio import inspect import io import json import logging import ntpath import os -import uuid import zipfile import re -import threading from typing import Any, Dict, List, Optional, Tuple, Union import yaml from nexent.skills import SkillManager from nexent.skills.skill_loader import SkillLoader -from nexent.core.utils.observer import MessageObserver -from nexent.core.agents.agent_model import ModelConfig from consts.const import ( CAN_EDIT_ALL_USER_ROLES, CONTAINER_SKILLS_PATH, @@ -35,9 +30,6 @@ from database import skill_db from database.group_db import query_group_ids_by_user from database.user_tenant_db import get_user_tenant_by_user_id -from agents.skill_creation_agent import create_skill_from_request -from utils.prompt_template_utils import get_skill_creation_simple_prompt_template -from utils.content_classifier_utils import ContentClassifier from utils.str_utils import convert_list_to_string logger = logging.getLogger(__name__) @@ -2573,291 +2565,6 @@ def export_skills_by_names( return results -def classify_streaming_content( - content: str, - classifier: Any -) -> List[Dict[str, Any]]: - """Classify streaming content using the ContentClassifier. - - Args: - content: Raw streaming content to classify - classifier: ContentClassifier instance - - Returns: - List of classified event dictionaries - """ - return classifier.classify(content) - - -class SkillCreationStreamService: - """Service for handling skill creation streaming operations.""" - - def __init__(self, skill_service: Optional["SkillService"] = None): - """Initialize the stream service. - - Args: - skill_service: Optional SkillService instance for accessing skill manager - """ - self.skill_service = skill_service or SkillService() - - def get_skill_manager_local_dir(self) -> str: - """Get local_skills_dir from SkillManager. - - Returns: - Local skills directory path - """ - return self.skill_service.skill_manager.resolve_tenant_dir( - tenant_id=self.skill_service.tenant_id - ) - - def create_classifier(self) -> "ContentClassifier": - """Create a new ContentClassifier instance. - - Returns: - New ContentClassifier instance - """ - from utils.content_classifier_utils import ContentClassifier - return ContentClassifier() - - def classify_content( - self, - content: str, - classifier: "ContentClassifier" - ) -> List[Dict[str, Any]]: - """Classify streaming content using the provided classifier. - - Args: - content: Raw streaming content to classify - classifier: ContentClassifier instance - - Returns: - List of classified event dictionaries - """ - return classifier.classify(content) - - -def create_skill_creation_stream_generator( - observer: Any, - classifier: "ContentClassifier", -) -> Any: - """Create a generator that processes observer messages and yields SSE events. - - Args: - observer: MessageObserver instance with cached messages - classifier: ContentClassifier instance for content classification - - Yields: - SSE-formatted event strings - """ - import json - from consts.const import STREAMABLE_CONTENT_TYPES - - cached = observer.get_cached_message() - for msg in cached: - if isinstance(msg, str): - try: - data = json.loads(msg) - msg_type = data.get("type", "") - content = data.get("content", "") - - if msg_type == "step_count": - yield f"data: {json.dumps({'type': 'step_count', 'content': content}, ensure_ascii=False)}\n\n" - elif msg_type in STREAMABLE_CONTENT_TYPES: - for event in classifier.classify(content): - yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - except (json.JSONDecodeError, Exception): - pass - - -def format_final_answer_sse(classifier: "ContentClassifier", final_result: str) -> List[str]: - """Format final answer content into SSE event strings. - - Args: - classifier: ContentClassifier instance for content classification - final_result: Final answer content to format - - Returns: - List of SSE-formatted event strings - """ - import json - - events = [] - for event in classifier.classify(final_result): - events.append(f"data: {json.dumps(event, ensure_ascii=False)}\n\n") - return events - - -# ========== Skill Creation Task Manager ========== - - -class SkillCreationTaskManager: - """Singleton manager to track active skill creation threads and their stop events.""" - - _instance: Optional["SkillCreationTaskManager"] = None - _lock = threading.Lock() - - def __new__(cls) -> "SkillCreationTaskManager": - if cls._instance is None: - with cls._lock: - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._tasks: Dict[str, Tuple[threading.Thread, threading.Event]] = {} - cls._instance._tasks_lock = threading.Lock() - return cls._instance - - def register_task(self, task_id: str, thread: threading.Thread, stop_event: threading.Event) -> None: - """Register a new skill creation task. - - Args: - task_id: Unique identifier for the task - thread: The thread running the skill creation - stop_event: Event to signal stop request - """ - with self._tasks_lock: - self._tasks[task_id] = (thread, stop_event) - logger.info(f"Registered skill creation task: {task_id}") - - def unregister_task(self, task_id: str) -> None: - """Unregister a completed skill creation task. - - Args: - task_id: Unique identifier for the task - """ - with self._tasks_lock: - if task_id in self._tasks: - del self._tasks[task_id] - logger.info(f"Unregistered skill creation task: {task_id}") - - def stop_task(self, task_id: str) -> bool: - """Signal a skill creation task to stop. - - Args: - task_id: Unique identifier for the task - - Returns: - True if the task was found and stop was signaled, False otherwise - """ - with self._tasks_lock: - if task_id in self._tasks: - _, stop_event = self._tasks[task_id] - stop_event.set() - logger.info(f"Stop signal sent for skill creation task: {task_id}") - return True - return False - - def is_task_running(self, task_id: str) -> bool: - """Check if a task is still running. - - Args: - task_id: Unique identifier for the task - - Returns: - True if the task exists and is still alive - """ - with self._tasks_lock: - if task_id in self._tasks: - thread, _ = self._tasks[task_id] - return thread.is_alive() - return False - - -# Singleton instance -skill_creation_task_manager = SkillCreationTaskManager() - - -# ========== Skill Creation Stream Service ========== - - -def stream_skill_creation( - user_request: str, - language: str, - model_config: "ModelConfig", - existing_skill: Optional[Dict[str, Any]] = None, - complexity: str = "simple", -) -> tuple[str, Any]: - """Stream skill creation process as an async generator. - - This function handles all the business logic for skill creation: - - Loads prompt template - - Creates observer, stop_event, and classifier - - Registers the task with the task manager - - Starts the agent thread - - Yields SSE events until completion - - Args: - user_request: User's skill description request - language: Language code (e.g., "zh", "en") - model_config: Model configuration - existing_skill: Optional existing skill for modification - complexity: Skill complexity level ("simple" or "complicated") - - Returns: - Tuple of (task_id, generator_function) - The task_id should be passed to the caller for stop functionality - """ - task_id = str(uuid.uuid4()) - - async def generate(): - is_task_registered = False - observer = None - classifier = None - - try: - # Load prompt template - template = get_skill_creation_simple_prompt_template( - language=language, - existing_skill=existing_skill, - complexity=complexity - ) - - # Create observer and classifier - observer = MessageObserver(lang=language) - stop_event = threading.Event() - classifier = ContentClassifier() - - # Get local skills directory - local_skills_dir = get_skill_manager().resolve_tenant_dir(tenant_id=None) - - def run_task(): - create_skill_from_request( - system_prompt=template.get("system_prompt", ""), - user_prompt=user_request, - model_config_list=[model_config], - observer=observer, - stop_event=stop_event, - local_skills_dir=local_skills_dir - ) - - thread = threading.Thread(target=run_task) - - # Register task before starting - skill_creation_task_manager.register_task(task_id, thread, stop_event) - is_task_registered = True - - thread.start() - - while thread.is_alive(): - for event in create_skill_creation_stream_generator(observer, classifier): - yield event - await asyncio.sleep(0.1) - - thread.join() - - for event in create_skill_creation_stream_generator(observer, classifier): - yield event - - yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n" - - except Exception as e: - logger.error(f"Error in stream_skill_creation: {e}") - yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n" - finally: - if is_task_registered: - skill_creation_task_manager.unregister_task(task_id) - - return task_id, generate - - # ============== Skill List Initialization ============== diff --git a/backend/utils/content_classifier_utils.py b/backend/utils/content_classifier_utils.py index 373cae61fb..7907655a5c 100644 --- a/backend/utils/content_classifier_utils.py +++ b/backend/utils/content_classifier_utils.py @@ -27,6 +27,9 @@ def __init__(self): self.current_file_path: Optional[str] = None self.buffer = "" self.tag_count = 0 + self.saw_control_tag = False + self._origin_type: Optional[str] = None + self._state_before_file = "others" self._known_tags = { "", "", @@ -36,22 +39,60 @@ def __init__(self): } self._pending_file_path: Optional[str] = None - def classify(self, chunk: str) -> List[Dict[str, Any]]: - """Process streaming chunk and return list of classified events.""" + def classify( + self, + chunk: str, + origin_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Process one streaming chunk and return classified delta events. + + ``origin_type`` preserves the upstream observer type for content outside + the XML control blocks. Content inside a control block is emitted with + a semantic NL2Skill type and carries the upstream type as metadata. + """ results = [] + self._origin_type = origin_type self.buffer += chunk + if len(self.buffer) > self.MAX_BUFFER_SIZE: + overflow = self.buffer[:-self.MAX_BUFFER_SIZE] + self.buffer = self.buffer[-self.MAX_BUFFER_SIZE:] + event = self._create_event(overflow) + if event: + results.append(event) + while self.buffer: if self.buffer.startswith("<"): if ">" not in self.buffer: break - results.extend(self._process_tag_start()) + events = self._process_tag_start() + if events is None: + break + results.extend(events) else: results.extend(self._process_non_tag_content()) return results - def _process_tag_start(self) -> List[Dict[str, Any]]: + def flush(self, origin_type: Optional[str] = None) -> List[Dict[str, Any]]: + """Emit any non-tag tail left in the incremental buffer.""" + if origin_type is not None: + self._origin_type = origin_type + results = [] + while self.buffer: + if self.buffer.startswith("<") and ">" in self.buffer: + events = self._process_tag_start(final=True) + if events is not None: + results.extend(events) + continue + content = self.buffer + self.buffer = "" + event = self._create_event(content) + if event: + results.append(event) + return results + + def _process_tag_start(self, final: bool = False) -> Optional[List[Dict[str, Any]]]: """Process buffer when it starts with '<' - extracts and handles tags.""" results = [] gt_pos = self.buffer.index(">") @@ -59,6 +100,11 @@ def _process_tag_start(self) -> List[Dict[str, Any]]: matched = self._match_known_tag_with_buffer(potential_tag) if matched: + 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")): + 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: results.extend(self._emit_dos_protected_content()) @@ -71,11 +117,20 @@ def _handle_matched_tag(self, gt_pos: int, potential_tag: str, matched_tag: str) """Handle a successfully matched tag and process following content.""" results = [] if self.tag_count >= self.MAX_TAG_COUNT: - self.buffer = self.buffer[gt_pos + 1:] + remaining = self.buffer[gt_pos + 1:] + if remaining.startswith("\r\n"): + remaining = remaining[2:] + elif remaining.startswith("\n"): + remaining = remaining[1:] + self.buffer = remaining return results self.tag_count += 1 content_after_tag = self.buffer[gt_pos + 1:] + if content_after_tag.startswith("\r\n"): + content_after_tag = content_after_tag[2:] + elif content_after_tag.startswith("\n"): + content_after_tag = content_after_tag[1:] self.buffer = "" event = self._handle_tag(matched_tag) @@ -178,22 +233,38 @@ def _create_event(self, content: str) -> Dict[str, Any]: if not content: return {} + metadata = ( + {"origin_type": self._origin_type} + if self._origin_type + else {} + ) if self.state == "skill_body": - return {"type": "skill_body", "content": content} + return {"type": "skill_body", "content": content, "path": "SKILL.md", **metadata} elif self.state == "file": - return {"type": "file_content", "content": content, "path": self.current_file_path} + return { + "type": "file_content", + "content": content, + "path": self.current_file_path, + **metadata, + } elif self.state == "summary": - return {"type": "summary", "content": content} + return {"type": "summary", "content": content, **metadata} else: - return {"type": "others", "content": content} + return { + "type": self._origin_type or "others", + "content": content, + **metadata, + } def _handle_tag(self, tag: str) -> Optional[Dict[str, Any]]: """Handle matched tag and update state.""" if tag == "": + self.saw_control_tag = True self.state = "skill_body" return None elif tag == "": + self.saw_control_tag = True self.state = "summary" return None @@ -205,15 +276,26 @@ def _handle_tag(self, tag: str) -> Optional[Dict[str, Any]]: return None elif tag == "": + self.saw_control_tag = True + self._state_before_file = self.state self.state = "file" self.current_file_path = self._pending_file_path self._pending_file_path = None - return {"type": "file_content", "content": "", "path": self.current_file_path, "is_new_file": True} + event = { + "type": "file_content", + "content": "", + "path": self.current_file_path, + "is_new_file": True, + } + if self._origin_type: + event["origin_type"] = self._origin_type + return event elif tag == "": if self.state == "file": - self.state = "skill_body" + self.state = self._state_before_file self.current_file_path = None + self._state_before_file = "others" return None return None diff --git a/backend/utils/prompt_template_utils.py b/backend/utils/prompt_template_utils.py index cb56ee5893..26d7c210c7 100644 --- a/backend/utils/prompt_template_utils.py +++ b/backend/utils/prompt_template_utils.py @@ -1,6 +1,6 @@ import logging import os -from typing import Dict, Any, Optional +from typing import Any, Dict, List, Optional import yaml @@ -252,7 +252,9 @@ def get_cluster_summary_reduce_prompt_template(language: str = LANGUAGE["ZH"]) - def get_skill_creation_simple_prompt_template( language: str = LANGUAGE["ZH"], existing_skill: Optional[Dict[str, Any]] = None, - complexity: str = "simple" + complexity: str = "simple", + user_request: str = "", + target_files: Optional[List[str]] = None, ) -> Dict[str, str]: """ Get skill creation prompt template with Jinja2 rendering. @@ -266,6 +268,8 @@ def get_skill_creation_simple_prompt_template( existing_skill: Optional dict containing existing skill info for update scenarios. Expected keys: name, description, tags, content complexity: Complexity level ('simple' or 'complicated') + user_request: Current conversation turn request + target_files: Existing skill files explicitly selected for this turn Returns: Dict[str, str]: Template with keys 'system_prompt' and 'user_prompt', rendered with variables @@ -295,9 +299,17 @@ def get_skill_creation_simple_prompt_template( with open(absolute_template_path, 'r', encoding='utf-8') as f: template_data = yaml.safe_load(f) - # Prepare template context with existing_skill info + # A draft snapshot is supplied for every interactive turn, including the empty initial draft. + existing_skill_content = "" + if isinstance(existing_skill, dict): + existing_skill_content = str(existing_skill.get("content") or "").strip() + + # Prepare template context with existing_skill info. context = { - "existing_skill": existing_skill + "existing_skill": existing_skill, + "has_existing_skill_content": bool(existing_skill_content), + "user_request": user_request, + "target_files": target_files or [], } # Render templates with Jinja2 diff --git a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx index e906b4d7ec..d19308d5ba 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/SkillBuildModal.tsx @@ -1,6 +1,13 @@ "use client"; -import { useState, useEffect, useMemo, useRef, type ChangeEvent } from "react"; +import { + useState, + useEffect, + useMemo, + useRef, + useCallback, + type ChangeEvent, +} from "react"; import { useTranslation } from "react-i18next"; import { Modal, @@ -13,34 +20,15 @@ import { Spin, Tooltip, } from "antd"; -import { - Upload as UploadIcon, - Send, - Trash2, - MessageCircle, - Box, - Bot, - Loader2, - Square, -} from "lucide-react"; -import { - extractSkillInfo, - extractSkillInfoFromContent, -} from "@/lib/skillFileUtils"; +import { Upload as UploadIcon, Trash2, MessageCircle, Box } from "lucide-react"; +import { extractSkillInfo } from "@/lib/skillFileUtils"; import yaml from "js-yaml"; -import { - type SkillFormData, - type ChatMessage, - type SkillFileContent, -} from "@/types/skill"; +import { type SkillFormData, type SkillFileContent } from "@/types/skill"; import { fetchSkillsList, submitSkillForm, submitSkillFromFile, findSkillByName, - createSkillStream, - stopSkillCreation, - getThinkingSteps, type SkillListItem, type SkillData, } from "@/services/skillService"; @@ -52,12 +40,13 @@ import { type SkillFileNode, } from "@/services/agentConfigService"; import { normalizeSkillFiles } from "@/lib/skillFileUtils"; -import { MarkdownRenderer } from "@/components/common/markdownRenderer"; import log from "@/lib/logger"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { USER_ROLES } from "@/const/auth"; import { useGroupDetails, useGroupList } from "@/hooks/group/useGroupList"; import SkillDraftPanel from "./SkillDraftPanel"; +import { Nl2SkillChatPanel } from "../../../newchat/assistant-ui/nl2skill-chat-panel"; +import type { Nl2SkillStreamEvent } from "../../../newchat/adapter/remote-chat-model-adapter"; const { TextArea } = Input; @@ -113,31 +102,6 @@ function stripLeadingSkillFrontmatter(content: string): string { return normalizedContent; } -function mergeGeneratedSkillTabs( - currentTabs: SkillFileContent[], - generatedTabs: SkillFileContent[], - skillContent: string -) { - const generatedByPath = new Map( - generatedTabs.map((tab) => [tab.path, tab.content]) - ); - const currentPaths = new Set(currentTabs.map((tab) => tab.path)); - const updatedTabs = currentTabs.map((tab) => { - if (tab.path === "SKILL.md") { - return { ...tab, content: skillContent }; - } - const generatedContent = generatedByPath.get(tab.path); - return generatedContent ? { ...tab, content: generatedContent } : tab; - }); - const newTabs = generatedTabs.filter((tab) => !currentPaths.has(tab.path)); - const finalTabs = [...updatedTabs, ...newTabs].sort((a, b) => { - if (a.path === "SKILL.md") return -1; - if (b.path === "SKILL.md") return 1; - return a.path.localeCompare(b.path); - }); - return { updatedTabs, finalTabs }; -} - function flattenSkillFiles( nodes: SkillFileNode[], skillName: string @@ -230,14 +194,7 @@ export default function SkillBuildModal({ useState(""); const [uploadExtractingName, setUploadExtractingName] = useState(false); - // Interactive creation state - const [chatMessages, setChatMessages] = useState([]); - const [chatInput, setChatInput] = useState(""); - const [isChatLoading, setIsChatLoading] = useState(false); - const [thinkingDescription, setThinkingDescription] = useState(""); - const [isThinkingVisible, setIsThinkingVisible] = useState(false); const [interactiveSkillName, setInteractiveSkillName] = useState(""); - const chatContainerRef = useRef(null); // Content input streaming state - multi-file tabs const [skillTabs, setSkillTabs] = useState([ @@ -246,11 +203,11 @@ export default function SkillBuildModal({ const [activeSkillTab, setActiveSkillTab] = useState("SKILL.md"); const [isStreaming, setIsStreaming] = useState(false); - // Summary content for chat bubble - const [summaryContent, setSummaryContent] = useState(""); - - // Frontmatter buffer for streaming - accumulate and parse at completion - const frontmatterBufferRef = useRef(""); + const skillBodyBufferRef = useRef(""); + const streamedBodyLengthRef = useRef(0); + const streamHasDraftRef = useRef(false); + const previousTabsRef = useRef(null); + const previousDraftFieldsRef = useRef | null>(null); // Refs for per-tab scroll state: tracks whether each textarea should auto-scroll // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -286,33 +243,11 @@ export default function SkillBuildModal({ }); }; - // Track if component is mounted to prevent state updates after unmount - const isMountedRef = useRef(true); - const currentAssistantIdRef = useRef(""); - // Track if streaming is complete to prevent late onFormContent callbacks from overwriting cleaned content - const isStreamingCompleteRef = useRef(false); - // Track current tabs during streaming to avoid stale closure issues const streamingTabsRef = useRef([ { path: "SKILL.md", content: "" }, ]); - // AbortController ref for stopping streaming - const abortControllerRef = useRef(null); - - // Task ID ref for backend stop API - const taskIdRef = useRef(""); - - // Multi-turn conversation state: accumulated skill draft from previous turns. - // When the user sends a follow-up message, this draft is passed as existing_skill - // so the backend can refine the skill rather than generating from scratch. - const [accumulatedDraft, setAccumulatedDraft] = useState<{ - name: string; - description: string; - tags: string[]; - content: string; - } | null>(null); - useEffect(() => { if (!isOpen) return; let cancelled = false; @@ -340,58 +275,27 @@ export default function SkillBuildModal({ useEffect(() => { if (!isOpen) { - // Abort any ongoing streaming request - if (abortControllerRef.current) { - abortControllerRef.current.abort("Modal closed"); - abortControllerRef.current = null; - } - // Reset task ID - taskIdRef.current = ""; setActiveTab("interactive"); setUploadFile(null); - setChatMessages([]); - setChatInput(""); setInteractiveSkillName(""); setUploadExtractingName(false); setUploadExtractedSkillName(""); - setThinkingDescription(""); - setIsThinkingVisible(false); setSkillTabs([{ path: "SKILL.md", content: "" }]); streamingTabsRef.current = [{ path: "SKILL.md", content: "" }]; shouldAutoScrollRef.current = {}; setActiveSkillTab("SKILL.md"); setIsStreaming(false); - setSummaryContent(""); - currentAssistantIdRef.current = ""; - setAccumulatedDraft(null); + skillBodyBufferRef.current = ""; + streamedBodyLengthRef.current = 0; + streamHasDraftRef.current = false; + previousTabsRef.current = null; + previousDraftFieldsRef.current = null; setLoadedEditSkillId(null); setEditFilesError(null); setIsLoadingEditFiles(false); } }, [isOpen]); - // Track component mount status for async callback safety - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - // Sync summary content to the current assistant chat message for real-time display. - useEffect(() => { - if (!currentAssistantIdRef.current) return; - setChatMessages((prev) => { - if (!prev.some((m) => m.id === currentAssistantIdRef.current)) - return prev; - return prev.map((msg) => - msg.id === currentAssistantIdRef.current - ? { ...msg, content: summaryContent } - : msg - ); - }); - }, [summaryContent]); - // Detect create/update mode when extracted skill name changes (upload tab) const [uploadIsCreateMode, setUploadIsCreateMode] = useState(true); useEffect(() => { @@ -604,348 +508,160 @@ export default function SkillBuildModal({ } }; - // Helper function to update tab content - const updateTabContent = (tabPath: string, content: string) => { - setSkillTabs((prev) => { - const newTabs = prev.map((tab) => - tab.path === tabPath ? { ...tab, content: tab.content + content } : tab - ); - streamingTabsRef.current = newTabs; - return newTabs; - }); - // Scroll to bottom after content update during streaming - if (isStreaming) { - setTimeout(() => scrollTextareaToBottom(tabPath), 0); - } - }; - - const ensureStreamingTab = (tabPath: string) => { - setSkillTabs((prev) => { - const newTabs = prev.find((tab) => tab.path === tabPath) - ? prev - : [...prev, { path: tabPath, content: "" }]; - streamingTabsRef.current = newTabs; - shouldAutoScrollRef.current[tabPath] = true; - return newTabs; - }); - }; - - // Assemble skill files into XML-like format for agent consumption - const assembleSkillContent = (tabs: SkillFileContent[]): string => { - const parts: string[] = []; - - for (const tab of tabs) { - if (tab.path === "SKILL.md") { - parts.push(`\n${tab.content}\n`); - } else { - parts.push(`\n${tab.content}\n`); - } - } - - return parts.join("\n\n"); - }; + useEffect(() => { + streamingTabsRef.current = skillTabs; + }, [skillTabs]); - // Parse frontmatter YAML and update form fields - const parseAndUpdateFrontmatter = (frontmatterYaml: string) => { + const parseAndApplyStreamedFrontmatter = (frontmatterYaml: string) => { const parsed = parseStreamedFrontmatter(frontmatterYaml); - if (!parsed) { - return; - } - + if (!parsed) return; const updates: Partial = {}; if (parsed.name && !isEditMode) { updates.name = parsed.name; setInteractiveSkillName(parsed.name); } - if (parsed.description) { - updates.description = parsed.description; - } - if (parsed.tags.length > 0) { - updates.tags = parsed.tags; - } - if (Object.keys(updates).length > 0) { - form.setFieldsValue(updates); - } + if (parsed.description) updates.description = parsed.description; + if (parsed.tags.length > 0) updates.tags = parsed.tags; + if (Object.keys(updates).length > 0) form.setFieldsValue(updates); }; - // Handle chat send for interactive creation - const handleChatSend = async () => { - if (!chatInput.trim() || isChatLoading) return; - - const currentInput = chatInput.trim(); - setChatInput(""); - - // Read current form fields to provide context to the model. - const formValues = form.getFieldsValue(); - const draft = accumulatedDraft; - - // Assemble skill content from all tabs - const assembledContent = assembleSkillContent(skillTabs); - const formContext = [ - formValues.name - ? t("skillManagement.chat.context.name", { name: formValues.name }) - : "", - formValues.description - ? t("skillManagement.chat.context.description", { - description: formValues.description, - }) - : "", - formValues.tags?.length - ? t("skillManagement.chat.context.tags", { - tags: formValues.tags.join(", "), - }) - : "", - assembledContent - ? t("skillManagement.chat.context.content", { - content: assembledContent, - }) - : "", - ] - .filter(Boolean) - .join("\n\n"); - - const userMessage: ChatMessage = { - id: Date.now().toString(), - role: "user", - content: currentInput, - timestamp: new Date(), + const getDraftSnapshot = useCallback((): Record => { + const values = form.getFieldsValue(); + return { + name: values.name || "", + description: values.description || "", + tags: values.tags || [], + files: streamingTabsRef.current.map((tab) => ({ ...tab })), }; - - setChatMessages((prev) => [...prev, userMessage]); - setIsChatLoading(true); - setIsThinkingVisible(true); - setThinkingDescription(t("skillManagement.generatingSkill")); - - // Clear content input before streaming — start fresh so the streamed content - // reflects the (possibly refined) result of this turn. - setSkillTabs([{ path: "SKILL.md", content: "" }]); - streamingTabsRef.current = [{ path: "SKILL.md", content: "" }]; + }, [form]); + + const targetedFilesRef = useRef(null); + + const beginDraftStream = () => { + if (streamHasDraftRef.current) return; + previousTabsRef.current = streamingTabsRef.current.map((tab) => ({ + ...tab, + })); + const currentFields = form.getFieldsValue(); + previousDraftFieldsRef.current = { + name: currentFields.name, + description: currentFields.description, + tags: currentFields.tags, + }; + const targets = targetedFilesRef.current; + const initialTabs = targets?.length + ? streamingTabsRef.current.map((tab) => + targets.includes(tab.path) ? { ...tab, content: "" } : { ...tab } + ) + : [{ path: "SKILL.md", content: "" }]; + streamHasDraftRef.current = true; + skillBodyBufferRef.current = ""; + streamedBodyLengthRef.current = 0; shouldAutoScrollRef.current = { "SKILL.md": true }; + streamingTabsRef.current = initialTabs; + setSkillTabs(initialTabs); setActiveSkillTab("SKILL.md"); - setIsStreaming(true); - setSummaryContent(""); - isStreamingCompleteRef.current = false; - - const assistantId = (Date.now() + 1).toString(); - - setChatMessages((prev) => [ - ...prev, - { - id: assistantId, - role: "assistant", - content: "", - timestamp: new Date(), - }, - ]); - - currentAssistantIdRef.current = assistantId; - - try { - // Create AbortController for this request - abortControllerRef.current = new AbortController(); - - // On first turn, no existing_skill is sent → backend creates from scratch. - // On subsequent turns (accumulatedDraft exists), existing_skill is passed - // → backend follows the modify-workflow template and refines the draft. - const userPrompt = formContext - ? t("skillManagement.chat.userRequestWithContext", { - request: currentInput, - context: formContext, - }) - : t("skillManagement.chat.userRequest", { request: currentInput }); - - await createSkillStream( - { - user_request: userPrompt, - existing_skill: draft - ? { - name: draft.name || formValues.name || "", - description: draft.description || formValues.description || "", - tags: draft.tags?.length ? draft.tags : formValues.tags || [], - content: assembledContent, - } - : undefined, - complexity: "complicated", - language: i18n.language?.startsWith("en") ? "en" : "zh", - }, - { - onTaskId: (taskId) => { - taskIdRef.current = taskId; - }, - onThinkingUpdate: (step, desc) => { - setThinkingDescription( - desc || t("skillManagement.generatingSkill") - ); - }, - onThinkingVisible: (visible) => { - setIsThinkingVisible(visible); - }, - onStepCount: (step) => { - setThinkingDescription( - getThinkingSteps(i18n.language).find((s) => s.step === step)?.description || - t("skillManagement.generatingSkill") - ); - }, - onFrontmatter: (content) => { - frontmatterBufferRef.current += content; - const parsed = parseStreamedFrontmatter( - frontmatterBufferRef.current - ); - if (!parsed) return; - if (parsed.name && !isEditMode) { - form.setFieldsValue({ name: parsed.name }); - setInteractiveSkillName(parsed.name); - } - if (parsed.description) { - form.setFieldsValue({ description: parsed.description }); - } - if (parsed.tags.length > 0) { - form.setFieldsValue({ tags: parsed.tags }); - } - }, - onSkillBody: (content) => { - if (isStreamingCompleteRef.current) return; - setSummaryContent(""); - // Frontmatter is complete when skill_body starts - clear the buffer - frontmatterBufferRef.current = ""; - // Only add body content to textarea (no frontmatter) - updateTabContent("SKILL.md", content); - }, - onFileContent: (path, content, isNewFile) => { - if (isStreamingCompleteRef.current) return; - setSummaryContent(""); - - if (isNewFile) { - ensureStreamingTab(path); - } + }; - updateTabContent(path, content); - setActiveSkillTab(path); - }, - onSummary: (content) => { - if (isStreamingCompleteRef.current) return; - setSummaryContent((prev) => prev + content); - }, - onDone: (result) => { - if (!isMountedRef.current) return; - setIsThinkingVisible(false); - setIsStreaming(false); - currentAssistantIdRef.current = ""; - isStreamingCompleteRef.current = true; - - // Get SKILL.md content and strip frontmatter for textarea display - const skillTab = result.skillTabs.find( - (t) => t.path === "SKILL.md" - ); - const fullContent = skillTab?.content || ""; - - if (fullContent || result.skillTabs.length > 0) { - // Strip frontmatter from SKILL.md content for textarea display - const skillInfo = extractSkillInfoFromContent(fullContent); - const contentWithoutFrontmatter = - skillInfo?.contentWithoutFrontmatter || ""; - - const currentTabs = streamingTabsRef.current; - const { updatedTabs, finalTabs } = mergeGeneratedSkillTabs( - currentTabs, - result.skillTabs, - contentWithoutFrontmatter - ); - - setSkillTabs(finalTabs); - - if (skillInfo?.name && !isEditMode) { - form.setFieldsValue({ name: skillInfo.name }); - setInteractiveSkillName(skillInfo.name); - } - if (skillInfo?.description) { - form.setFieldsValue({ description: skillInfo.description }); - } - if (skillInfo?.tags?.length) { - form.setFieldsValue({ tags: skillInfo.tags }); - } - - // Update accumulated draft with assembled content for next turn - const assembledDraft = assembleSkillContent(updatedTabs); - const newDraft = { - name: skillInfo?.name || draft?.name || "", - description: skillInfo?.description || draft?.description || "", - tags: skillInfo?.tags?.length - ? skillInfo.tags - : draft?.tags || [], - content: assembledDraft, - }; - setAccumulatedDraft(newDraft); + const appendFileDelta = (path: string, content: string) => { + setSkillTabs((previous) => { + const next = previous.some((tab) => tab.path === path) + ? previous.map((tab) => + tab.path === path ? { ...tab, content: tab.content + content } : tab + ) + : [...previous, { path, content }]; + streamingTabsRef.current = next; + return next; + }); + }; - // Scroll to bottom after content is fully loaded - setTimeout(() => scrollTextareaToBottom("SKILL.md"), 0); + const appendSkillBodyDelta = (content: string) => { + skillBodyBufferRef.current += content; + const normalized = skillBodyBufferRef.current.replace(/^\r?\n/, ""); + if (!normalized.startsWith("---")) { + const delta = normalized.slice(streamedBodyLengthRef.current); + streamedBodyLengthRef.current = normalized.length; + if (delta) appendFileDelta("SKILL.md", delta); + return; + } - message.success(t("skillManagement.message.skillReadyForSave")); - } - }, - onError: (errorMsg) => { - log.error("Interactive skill creation error:", errorMsg); - message.error(t("skillManagement.message.chatError")); - setChatMessages((prev) => prev.filter((m) => m.id !== assistantId)); - setIsStreaming(false); - currentAssistantIdRef.current = ""; - }, - }, - { signal: abortControllerRef.current.signal } + const frontmatterEnd = normalized.search(/\r?\n---(?:\r?\n|$)/); + if (frontmatterEnd < 0) { + parseAndApplyStreamedFrontmatter( + normalized.slice(3).replace(/^\r?\n/, "") ); - } catch (error) { - // Handle AbortError gracefully when user stops the stream - const err = error as Error; - if (err?.name === "AbortError") { - // User stopped - just reset states silently - setIsChatLoading(false); - setIsStreaming(false); - setIsThinkingVisible(false); - return; - } - log.error("Interactive skill creation error:", error); - message.error(t("skillManagement.message.chatError")); - setChatMessages((prev) => prev.filter((m) => m.id !== assistantId)); - setIsStreaming(false); - } finally { - abortControllerRef.current = null; - setIsChatLoading(false); + return; } + + const frontmatter = normalized + .slice(3, frontmatterEnd) + .replace(/^\r?\n/, ""); + parseAndApplyStreamedFrontmatter(frontmatter); + const delimiter = + normalized.slice(frontmatterEnd).match(/^\r?\n---(?:\r?\n|$)/)?.[0] || ""; + const body = normalized.slice(frontmatterEnd + delimiter.length); + const delta = body.slice(streamedBodyLengthRef.current); + streamedBodyLengthRef.current = body.length; + if (delta) appendFileDelta("SKILL.md", delta); }; - // Handle stop - cancel the ongoing streaming request - const handleStop = async () => { - // Call backend stop API first - if (taskIdRef.current) { - try { - await stopSkillCreation(taskIdRef.current); - } catch (error) { - log.error("Failed to stop backend task:", error); - } + const rollbackDraftStream = () => { + if (previousTabsRef.current) { + const restored = previousTabsRef.current.map((tab) => ({ ...tab })); + streamingTabsRef.current = restored; + setSkillTabs(restored); } - - // Abort frontend fetch - if (abortControllerRef.current) { - abortControllerRef.current.abort("User stopped"); - abortControllerRef.current = null; + if (previousDraftFieldsRef.current) { + form.setFieldsValue(previousDraftFieldsRef.current); + if (!isEditMode) { + setInteractiveSkillName(previousDraftFieldsRef.current.name || ""); + } } - - // Reset all states - setIsChatLoading(false); + previousTabsRef.current = null; + previousDraftFieldsRef.current = null; + streamHasDraftRef.current = false; setIsStreaming(false); - setIsThinkingVisible(false); - currentAssistantIdRef.current = ""; - taskIdRef.current = ""; - isStreamingCompleteRef.current = true; }; - // Scroll to bottom of chat when new messages arrive - useEffect(() => { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = - chatContainerRef.current.scrollHeight; - } - }, [chatMessages]); + const handleNl2SkillStreamEvent = useCallback( + (event: Nl2SkillStreamEvent) => { + if (event.type === "target_files") { + targetedFilesRef.current = event.paths?.length ? event.paths : null; + } + if (event.type === "agent_new_run" || event.type === "step_count") { + setIsStreaming(true); + } + if (event.type === "skill_body" || event.type === "file_content") { + beginDraftStream(); + setIsStreaming(true); + } + if (event.type === "skill_body") { + appendSkillBodyDelta(event.content || ""); + } else if (event.type === "file_content") { + appendFileDelta(event.path || "file.txt", event.content || ""); + } else if (event.type === "done") { + previousTabsRef.current = null; + previousDraftFieldsRef.current = null; + streamHasDraftRef.current = false; + targetedFilesRef.current = null; + setIsStreaming(false); + if (skillBodyBufferRef.current) { + message.success(t("skillManagement.message.skillReadyForSave")); + } + } else if (event.type === "error") { + targetedFilesRef.current = null; + rollbackDraftStream(); + message.error(t("skillManagement.message.chatError")); + } else if (event.type === "stream_closed") { + if (streamHasDraftRef.current) rollbackDraftStream(); + setIsStreaming(false); + targetedFilesRef.current = null; + } + }, + // The helpers above operate on refs and stable React/Ant Design setters. + // eslint-disable-next-line react-hooks/exhaustive-deps + [isEditMode, t] + ); const modalBodyFrame = "min(92vh, 760px)"; const modalViewportFrame = "calc(100vh - 32px)"; @@ -1110,131 +826,13 @@ export default function SkillBuildModal({ ); }; const renderChatPanel = () => ( -
-
- {chatMessages.length === 0 ? ( -
-
- -
-
- {isEditMode ? ( - <> -

- {t("skillManagement.chat.editGreetingTitle", { - name: editingSkillName, - })} -

-

- {t("skillManagement.chat.editGreetingBody")} -

- - ) : ( - <> -

{t("skillManagement.chat.createGreetingTitle")}

-

- {t("skillManagement.chat.createGreetingExample")} -

- - )} -
-
- ) : null} - {chatMessages.map((msg) => ( -
-
- {msg.role === "assistant" && - msg.id === currentAssistantIdRef.current && - isThinkingVisible ? ( -
- - {thinkingDescription ? ( - - {thinkingDescription} - - ) : null} -
- ) : msg.role === "assistant" ? ( -
- -
- ) : ( -
{msg.content}
- )} -
-
- ))} -
- -
-
- -