From 4bdab76ccfe97da249e4362ea5908dda6fd14e7a Mon Sep 17 00:00:00 2001 From: hm1229 Date: Wed, 5 Aug 2026 21:05:06 +0800 Subject: [PATCH 1/3] refactor(harness): remove legacy agent loop runtime --- AGENTS.md | 2 +- backend/app/core/agent_loop.py | 5751 ++--------------- ...ojection.py => conversation_projection.py} | 19 +- .../{legacy_graph_rules.py => graph_rules.py} | 25 +- .../app/core/legacy_general_skill_action.py | 265 - backend/app/core/legacy_knowledge_action.py | 474 -- .../app/core/legacy_reflection_coordinator.py | 560 -- backend/app/core/legacy_tool_action.py | 250 - ...cy_turn_finalizer.py => turn_finalizer.py} | 4 +- backend/tests/agent_golden/__init__.py | 1 - .../agent_golden/capture_legacy_fixtures.py | 101 - backend/tests/agent_golden/conftest.py | 17 - .../tests/agent_golden/contract_validation.py | 664 -- backend/tests/agent_golden/fixture_writer.py | 642 -- backend/tests/agent_golden/harness.py | 377 -- .../agent_golden/legacy_scenario_capture.py | 717 -- backend/tests/agent_golden/pairwise.py | 347 - .../agent_golden/scripted_dependencies.py | 145 - backend/tests/agent_golden/sse.py | 52 - backend/tests/agent_golden/support.py | 381 -- .../agent_golden/test_contract_assets.py | 797 --- .../tests/agent_golden/test_fixture_writer.py | 85 - .../agent_golden/test_legacy_http_golden.py | 354 - backend/tests/agent_golden/test_pairwise.py | 106 - .../tests/agent_golden/test_real_socket.py | 400 -- backend/tests/agent_golden/test_sse.py | 29 - backend/tests/agent_golden/test_support.py | 277 - .../tests/agent_golden/update_seed_corpus.py | 58 - backend/tests/test_agent_loop_completion.py | 2899 --------- .../test_agent_loop_decomposition_seams.py | 331 - backend/tests/test_chat_agent_binding.py | 89 - backend/tests/test_conversation_projection.py | 42 +- .../test_general_skill_provider_runtime.py | 96 +- backend/tests/test_general_skills.py | 1250 +--- ...acy_graph_rules.py => test_graph_rules.py} | 50 +- backend/tests/test_harness_v2.py | 23 +- .../tests/test_legacy_general_skill_action.py | 124 - backend/tests/test_reflection_agent_loop.py | 631 -- ...rn_finalizer.py => test_turn_finalizer.py} | 6 +- contracts/agent/v1/compatibility-matrix.json | 36 - .../agent/v1/conformance-report-phase0.json | 107 - .../agent/v1/corpus/legacy_skills/leave.json | 14 - .../corpus/legacy_skills/price_compare.json | 16 - .../v1/corpus/legacy_skills/purchase.json | 16 - .../corpus/production_seed/price_compare.json | 85 - .../v1/corpus/production_seed/purchase.json | 110 - .../v1/corpus/production_seed/refund.json | 119 - contracts/agent/v1/field-ownership.json | 150 - .../conversation.json | 240 - .../db_events.json | 158 - .../GT01-feedback-refresh-toggle/domain.json | 150 - .../provider.json | 31 - .../GT01-feedback-refresh-toggle/sse.json | 31 - .../GT01/GT01-sse/conversation.json | 122 - .../GT01/GT01-sse/db_events.json | 282 - .../GT01/GT01-sse/domain.json | 141 - .../GT01/GT01-sse/provider.json | 31 - .../GT01/GT01-sse/sse.json | 451 -- .../GT01/GT01-sync/conversation.json | 171 - .../GT01/GT01-sync/db_events.json | 132 - .../GT01/GT01-sync/domain.json | 141 - .../GT01/GT01-sync/provider.json | 31 - .../GT01/GT01-sync/sse.json | 31 - .../conversation.json | 160 - .../GT02-ask-refresh-continue/db_events.json | 1084 ---- .../GT02-ask-refresh-continue/domain.json | 168 - .../GT02-ask-refresh-continue/provider.json | 31 - .../GT02/GT02-ask-refresh-continue/sse.json | 945 --- .../GT03/GT03-false/conversation.json | 184 - .../GT03/GT03-false/db_events.json | 327 - .../GT03/GT03-false/domain.json | 190 - .../GT03/GT03-false/provider.json | 31 - .../GT03/GT03-false/sse.json | 31 - .../GT03/GT03-true/conversation.json | 184 - .../GT03/GT03-true/db_events.json | 327 - .../GT03/GT03-true/domain.json | 190 - .../GT03/GT03-true/provider.json | 31 - .../GT03/GT03-true/sse.json | 31 - .../GT04/GT04-merge/conversation.json | 184 - .../GT04/GT04-merge/db_events.json | 514 -- .../GT04/GT04-merge/domain.json | 355 - .../GT04/GT04-merge/provider.json | 31 - .../GT04/GT04-merge/sse.json | 31 - .../GT13/GT13-llm-error/conversation.json | 122 - .../GT13/GT13-llm-error/db_events.json | 348 - .../GT13/GT13-llm-error/domain.json | 114 - .../GT13/GT13-llm-error/provider.json | 31 - .../GT13/GT13-llm-error/sse.json | 672 -- .../GT15/GT15-full/conversation.json | 302 - .../GT15/GT15-full/db_events.json | 430 -- .../GT15/GT15-full/domain.json | 117 - .../GT15/GT15-full/provider.json | 31 - .../GT15/GT15-full/sse.json | 615 -- .../GT16/GT16-history/conversation.json | 159 - .../GT16/GT16-history/db_events.json | 282 - .../GT16/GT16-history/domain.json | 176 - .../GT16/GT16-history/provider.json | 31 - .../GT16/GT16-history/sse.json | 451 -- contracts/agent/v1/legacy-seams.json | 30 - contracts/agent/v1/legacy-skill-corpus.json | 31 - contracts/agent/v1/manifest.json | 51 - .../agent/v1/normalization-profiles.json | 46 - contracts/agent/v1/pairwise-manifest.json | 436 -- .../agent/v1/relationship-requirements.json | 214 - contracts/agent/v1/requirement-registry.json | 35 - contracts/agent/v1/scenario-catalog.json | 59 - contracts/agent/v1/scenario-vocabulary.json | 92 - .../schemas/compatibility-matrix.schema.json | 33 - .../v1/schemas/conformance-report.schema.json | 88 - .../conversation-projection.schema.json | 25 - .../v1/schemas/field-ownership.schema.json | 158 - .../v1/schemas/fixture-envelope.schema.json | 116 - .../graph-characterization.schema.json | 23 - .../v1/schemas/interaction-block.schema.json | 103 - .../agent/v1/schemas/legacy-seams.schema.json | 22 - .../schemas/legacy-skill-corpus.schema.json | 30 - .../agent/v1/schemas/manifest.schema.json | 61 - .../normalization-profiles.schema.json | 49 - .../v1/schemas/pairwise-manifest.schema.json | 149 - .../schemas/planes/conversation.schema.json | 54 - .../v1/schemas/planes/db-events.schema.json | 28 - .../v1/schemas/planes/domain.schema.json | 17 - .../legacy-provider-exchange.schema.json | 30 - .../agent/v1/schemas/planes/sse.schema.json | 36 - .../relationship-requirements.schema.json | 104 - .../schemas/requirement-registry.schema.json | 26 - .../v1/schemas/scenario-catalog.schema.json | 117 - .../schemas/scenario-vocabulary.schema.json | 29 - 128 files changed, 619 insertions(+), 31820 deletions(-) rename backend/app/core/{legacy_conversation_projection.py => conversation_projection.py} (92%) rename backend/app/core/{legacy_graph_rules.py => graph_rules.py} (94%) delete mode 100644 backend/app/core/legacy_general_skill_action.py delete mode 100644 backend/app/core/legacy_knowledge_action.py delete mode 100644 backend/app/core/legacy_reflection_coordinator.py delete mode 100644 backend/app/core/legacy_tool_action.py rename backend/app/core/{legacy_turn_finalizer.py => turn_finalizer.py} (95%) delete mode 100644 backend/tests/agent_golden/__init__.py delete mode 100644 backend/tests/agent_golden/capture_legacy_fixtures.py delete mode 100644 backend/tests/agent_golden/conftest.py delete mode 100644 backend/tests/agent_golden/contract_validation.py delete mode 100644 backend/tests/agent_golden/fixture_writer.py delete mode 100644 backend/tests/agent_golden/harness.py delete mode 100644 backend/tests/agent_golden/legacy_scenario_capture.py delete mode 100644 backend/tests/agent_golden/pairwise.py delete mode 100644 backend/tests/agent_golden/scripted_dependencies.py delete mode 100644 backend/tests/agent_golden/sse.py delete mode 100644 backend/tests/agent_golden/support.py delete mode 100644 backend/tests/agent_golden/test_contract_assets.py delete mode 100644 backend/tests/agent_golden/test_fixture_writer.py delete mode 100644 backend/tests/agent_golden/test_legacy_http_golden.py delete mode 100644 backend/tests/agent_golden/test_pairwise.py delete mode 100644 backend/tests/agent_golden/test_real_socket.py delete mode 100644 backend/tests/agent_golden/test_sse.py delete mode 100644 backend/tests/agent_golden/test_support.py delete mode 100644 backend/tests/agent_golden/update_seed_corpus.py delete mode 100644 backend/tests/test_agent_loop_completion.py delete mode 100644 backend/tests/test_agent_loop_decomposition_seams.py rename backend/tests/{test_legacy_graph_rules.py => test_graph_rules.py} (60%) delete mode 100644 backend/tests/test_legacy_general_skill_action.py delete mode 100644 backend/tests/test_reflection_agent_loop.py rename backend/tests/{test_legacy_turn_finalizer.py => test_turn_finalizer.py} (92%) delete mode 100644 contracts/agent/v1/compatibility-matrix.json delete mode 100644 contracts/agent/v1/conformance-report-phase0.json delete mode 100644 contracts/agent/v1/corpus/legacy_skills/leave.json delete mode 100644 contracts/agent/v1/corpus/legacy_skills/price_compare.json delete mode 100644 contracts/agent/v1/corpus/legacy_skills/purchase.json delete mode 100644 contracts/agent/v1/corpus/production_seed/price_compare.json delete mode 100644 contracts/agent/v1/corpus/production_seed/purchase.json delete mode 100644 contracts/agent/v1/corpus/production_seed/refund.json delete mode 100644 contracts/agent/v1/field-ownership.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/sse.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/conversation.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/db_events.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/domain.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/provider.json delete mode 100644 contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/sse.json delete mode 100644 contracts/agent/v1/legacy-seams.json delete mode 100644 contracts/agent/v1/legacy-skill-corpus.json delete mode 100644 contracts/agent/v1/manifest.json delete mode 100644 contracts/agent/v1/normalization-profiles.json delete mode 100644 contracts/agent/v1/pairwise-manifest.json delete mode 100644 contracts/agent/v1/relationship-requirements.json delete mode 100644 contracts/agent/v1/requirement-registry.json delete mode 100644 contracts/agent/v1/scenario-catalog.json delete mode 100644 contracts/agent/v1/scenario-vocabulary.json delete mode 100644 contracts/agent/v1/schemas/compatibility-matrix.schema.json delete mode 100644 contracts/agent/v1/schemas/conformance-report.schema.json delete mode 100644 contracts/agent/v1/schemas/conversation-projection.schema.json delete mode 100644 contracts/agent/v1/schemas/field-ownership.schema.json delete mode 100644 contracts/agent/v1/schemas/fixture-envelope.schema.json delete mode 100644 contracts/agent/v1/schemas/graph-characterization.schema.json delete mode 100644 contracts/agent/v1/schemas/interaction-block.schema.json delete mode 100644 contracts/agent/v1/schemas/legacy-seams.schema.json delete mode 100644 contracts/agent/v1/schemas/legacy-skill-corpus.schema.json delete mode 100644 contracts/agent/v1/schemas/manifest.schema.json delete mode 100644 contracts/agent/v1/schemas/normalization-profiles.schema.json delete mode 100644 contracts/agent/v1/schemas/pairwise-manifest.schema.json delete mode 100644 contracts/agent/v1/schemas/planes/conversation.schema.json delete mode 100644 contracts/agent/v1/schemas/planes/db-events.schema.json delete mode 100644 contracts/agent/v1/schemas/planes/domain.schema.json delete mode 100644 contracts/agent/v1/schemas/planes/legacy-provider-exchange.schema.json delete mode 100644 contracts/agent/v1/schemas/planes/sse.schema.json delete mode 100644 contracts/agent/v1/schemas/relationship-requirements.schema.json delete mode 100644 contracts/agent/v1/schemas/requirement-registry.schema.json delete mode 100644 contracts/agent/v1/schemas/scenario-catalog.schema.json delete mode 100644 contracts/agent/v1/schemas/scenario-vocabulary.schema.json diff --git a/AGENTS.md b/AGENTS.md index 228ddac2..14ca2715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -StaffDeck combines a Python 3.11+ FastAPI service with a React/TypeScript console. Backend application code lives in `backend/app/`; entry points such as `backend/single_port_app.py` support the desktop and single-port runtime. Backend tests are in `backend/tests/`, including contract-focused suites under `backend/tests/agent_golden/`. Frontend code is in `frontend-enterprise/src/`, with static assets in `frontend-enterprise/public/` and colocated `*.test.ts` or `*.test.tsx` files. Agent protocol fixtures and schemas belong in `contracts/agent/v1/`. Use `scripts/` for development lifecycle tooling and `packaging/` for platform release assets. +StaffDeck combines a Python 3.11+ FastAPI service with a React/TypeScript console. Backend application code lives in `backend/app/`; entry points such as `backend/single_port_app.py` support the desktop and single-port runtime. Backend tests are in `backend/tests/`, and the supported conversation runtime is Harness v2. Frontend code is in `frontend-enterprise/src/`, with static assets in `frontend-enterprise/public/` and colocated `*.test.ts` or `*.test.tsx` files. Use `scripts/` for development lifecycle tooling and `packaging/` for platform release assets. ## Build, Test, and Development Commands diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py index 77e652ca..9784aa95 100644 --- a/backend/app/core/agent_loop.py +++ b/backend/app/core/agent_loop.py @@ -1,36 +1,19 @@ from __future__ import annotations -import inspect -import queue -import threading -import traceback from collections.abc import Callable, Iterator -from dataclasses import dataclass from time import sleep -from types import SimpleNamespace from typing import Any, Literal from sqlmodel import Session, select from app.agents.branching import ( - is_bound_resource_visible_for_agent, - is_open_gallery_resource, model_for_agent, - visible_knowledge_base_ids, visible_published_skills, visible_skill, - visible_tool_rows, -) -from app.capabilities.contracts import CapabilityContext, GeneralSkillCatalog -from app.capabilities.local_general_skill import ( - GeneralSkillRuntimeSnapshot, - LocalGeneralSkillCatalog, - resource_ref_from_row, - runtime_snapshot_from_package, ) from app.channels.service_outbox import stage_channel_delivery from app.core.agent_identity_prompt import AgentIdentityPrompt -from app.core.cancellation import clear_chat_turn_cancelled, is_chat_turn_cancelled +from app.core.cancellation import clear_chat_turn_cancelled from app.core.conversation_context import build_conversation_context from app.core.harness_agent import HarnessExecutionCancelled from app.core.harness_session_lock import HarnessSessionBusy @@ -41,64 +24,30 @@ get_or_create_harness_session, ) from app.core.human_handoff_service import HumanHandoffService -from app.core.legacy_conversation_projection import LegacyConversationProjection -from app.core.legacy_general_skill_action import LegacyGeneralSkillAction -from app.core.legacy_graph_rules import LegacyGraphRules -from app.core.legacy_knowledge_action import ( - KNOWLEDGE_RESULTS_CACHE as _KNOWLEDGE_RESULTS_CACHE, # noqa: F401 - legacy import seam -) -from app.core.legacy_knowledge_action import ( - KNOWLEDGE_STEPS_SEEN as _KNOWLEDGE_STEPS_SEEN, # noqa: F401 - legacy import seam -) -from app.core.legacy_knowledge_action import LegacyKnowledgeAction -from app.core.legacy_reflection_coordinator import ( - LegacyReflectionCoordinator, - LegacyReflectionPolicy, -) -from app.core.legacy_tool_action import LegacyToolAction, LegacyToolActionCallbacks -from app.core.legacy_turn_finalizer import LegacyTurnFinalizer -from app.core.reflection_agent import ReflectionAgent, ReflectionDecision, action_needs_reflection +from app.core.conversation_projection import ConversationProjection +from app.core.graph_rules import GraphRules +from app.core.turn_finalizer import TurnFinalizer from app.core.response_generator import ( - FALLBACK_REPLY, ResponseGenerator, format_runtime_failure_reply, model_failure_suggestion, ) -from app.core.router import Router from app.core.skill_runtime import SkillRuntime -from app.core.slot_hydration_policy import SlotHydrationPolicy -from app.core.step_agent import StepAgent -from app.core.task_frame_policy import QueuedTaskContinuation, TaskFramePolicy -from app.core.tool_replay_policy import ( - TOOL_CALL_HISTORY_SLOT, - TOOL_RESULTS_SLOT, - ToolReplayPolicy, -) from app.db.models import ( - AgentEvent, AgentProfile, - AgentResourceBinding, ChatSession, - GeneralSkill, HarnessTurnRecord, HumanHandoffRequest, Message, ModelConfig, PersonaConfig, Skill, - Tool, UIConfig, new_id, utc_now, ) -from app.general_skills import GeneralSkillReader, GeneralSkillRunner, GeneralSkillSelector -from app.general_skills.schema import GeneralSkillRunResponse, GeneralSkillSelection -from app.harness.errors import HarnessExecutionError -from app.harness.sandbox import parse_network_policy -from app.knowledge import KnowledgeService from app.knowledge.citations import ( compact_knowledge_citation_labels, - knowledge_citations_from_results, restore_truncated_atomic_references, ) from app.llm import LLMClient, LLMError @@ -107,31 +56,23 @@ ) from app.llm.stage_protocol import stage_payload, unified_system_prompt from app.memory.jobs import enqueue_memory_capture -from app.memory.service import MemoryService, memory_read +from app.memory.service import MemoryService from app.observability import EventLog from app.observability.spans import llm_operation from app.session.helpers import public_session from app.session.session_schema import ( ChatTurnRequest, ChatTurnResponse, - KnowledgeQuery, - PendingTask, RouterDecision, StepAgentResult, ) -from app.tools import ToolExecutor -from app.tools.tool_schema import ToolCall, ToolError, ToolResult +from app.tools.tool_schema import ToolResult -StatusCallback = Callable[[str, dict[str, object]], None] STREAM_CHUNK_INTERVAL_SECONDS = 0.045 -DEFAULT_REFLECTION_MAX_ROUNDS = 1 -REFLECTION_MAX_ROUNDS_LIMIT = 5 MAX_TOOL_ACTIONS_PER_TURN = 32 MAX_TOOL_ACTIONS_PER_TURN_LIMIT = 100 GRAPH_PENDING_STEPS_SLOT = "_graph_pending_steps" -GENERAL_SKILL_TOOL_PREFIX = "general_skill." CANCELLED_ASSISTANT_REPLY = "已停止生成" -ERROR_TRACEBACK_CHAR_LIMIT = 6000 ExecutionFinalizeState = Literal["continued", "completed", "handoff"] @@ -177,41 +118,13 @@ def __init__(self, code: str, message: str): self.message = message -@dataclass -class PreparedTurn: - chat_session: ChatSession - model_config: ModelConfig - active_skill: Skill | None - router_decision: RouterDecision - step_result: StepAgentResult - tool_result: ToolResult | None - memory_context: list[dict[str, object]] - conversation_context: dict[str, object] - general_response: ChatTurnResponse | None = None - reply_override: str | None = None - user_message_id: str | None = None - - class AgentLoop: - def __init__( - self, - db: Session, - general_skill_catalog: GeneralSkillCatalog | None = None, - ) -> None: + def __init__(self, db: Session) -> None: self.db = db self.events = EventLog(db) - self.router = Router() self.runtime = SkillRuntime() - self.step_agent = StepAgent() - self.reflection_agent = ReflectionAgent() self.response_generator = ResponseGenerator() - self.general_skill_selector = GeneralSkillSelector() - self.general_skill_runner = GeneralSkillRunner() - self.general_skill_reader = GeneralSkillReader() - self.general_skill_catalog = general_skill_catalog or LocalGeneralSkillCatalog(db) - self.tool_executor = ToolExecutor(db) self.memory = MemoryService(db) - self._validated_general_skill_calls: set[tuple[str, str, str]] = set() def _turn_payload(self, payload: dict[str, Any], user_message_id: str | None) -> dict[str, Any]: data = dict(payload) @@ -220,204 +133,15 @@ def _turn_payload(self, payload: dict[str, Any], user_message_id: str | None) -> data.setdefault("turn_id", user_message_id) return data - def _hydrate_router_decision_from_context( - self, - chat_session: ChatSession, - router_decision: RouterDecision, - skills: list[Skill], - memory_context: list[dict[str, object]], - ) -> dict[str, Any]: - return SlotHydrationPolicy.hydrate( - chat_session, - router_decision, - skills, - memory_context, - patcher=self._slot_hydration_patch, - awaiting_trimmer=self._trim_satisfied_awaiting_fields, - ) - - def _slot_hydration_patch( - self, - skill: Skill | None, - slots: dict[str, Any], - memory_context: list[dict[str, object]], - ) -> dict[str, Any]: - return SlotHydrationPolicy.patch(skill, slots, memory_context) - - def _trim_satisfied_awaiting_fields( - self, router_decision: RouterDecision, slots: dict[str, Any] - ) -> list[str] | None: - return SlotHydrationPolicy.trim_satisfied_awaiting_fields(router_decision, slots) - def handle_turn(self, request: ChatTurnRequest) -> ChatTurnResponse: - LegacyKnowledgeAction.reset_turn() - router_decision: RouterDecision | None = None - step_result = StepAgentResult() - tool_result: ToolResult | None = None + engine = HarnessV2Engine(self) chat_session: ChatSession | None = None - memory_model_config: ModelConfig | None = None - prepared_user_message_id: str | None = None - harness_v2_engine: HarnessV2Engine | None = None + user_message_id: str | None = None + step_result = StepAgentResult(action="reply") try: - if self._uses_harness_v2(request): - harness_v2_engine = HarnessV2Engine(self) - response = harness_v2_engine.run(request) - harness_v2_engine.close() - return response - prepared = self._prepare_turn(request) - prepared_user_message_id = prepared.user_message_id - if prepared.general_response: - return prepared.general_response - chat_session = prepared.chat_session - memory_model_config = prepared.model_config - router_decision = prepared.router_decision - turn_followup_frames = self._turn_followup_task_frames(router_decision) - step_result = prepared.step_result - tool_result = prepared.tool_result - memory_context = prepared.memory_context - conversation_context = prepared.conversation_context - if prepared.reply_override is not None: - reply = prepared.reply_override - else: - if turn_followup_frames: - task_results = [ - self._task_response_context( - chat_session, - prepared.active_skill, - router_decision, - step_result, - tool_result, - ) - ] - primary_router_decision = router_decision - primary_step_result = step_result - primary_tool_result = tool_result - finalize_state = self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - prepared.active_skill, - router_decision, - step_result, - tool_result, - ) - paused_primary = None - if finalize_state == "continued" and prepared.active_skill: - paused_primary = self.runtime.suspend_current_skill(chat_session) - continuation = self._try_continue_pending_after_completion( - request, - chat_session, - prepared.model_config, - self._list_published_skills(request.tenant_id, chat_session.agent_id), - self._tools_with_general_skills( - request.tenant_id, - self._list_enabled_tools( - request.tenant_id, chat_session.agent_id - ), - chat_session.agent_id, - ), - self._get_persona_prompt( - request.tenant_id, chat_session.agent_id - ), - memory_context, - conversation_context, - "", - turn_task_frames=turn_followup_frames, - ) - if continuation: - task_results.extend(continuation.task_results) - if paused_primary: - if chat_session.active_skill_id: - self.runtime.suspend_current_skill(chat_session, enqueue=True) - self.runtime.restore_task_frame(chat_session, paused_primary) - self.db.commit() - self.db.refresh(chat_session) - router_decision = primary_router_decision - step_result = primary_step_result - tool_result = primary_tool_result - elif continuation: - router_decision = continuation.router_decision - step_result = continuation.step_result - tool_result = continuation.tool_result - response_active_skill = ( - prepared.active_skill - if paused_primary or not continuation - else continuation.active_skill - ) - reply = self._generate_reply_segment( - request.message, - chat_session, - response_active_skill, - router_decision, - step_result, - tool_result, - prepared.model_config, - self._get_persona_prompt(request.tenant_id, chat_session.agent_id), - memory_context, - conversation_context, - task_results, - ) - else: - reply = self._generate_reply_segment( - request.message, - chat_session, - prepared.active_skill, - router_decision, - step_result, - tool_result, - prepared.model_config, - self._get_persona_prompt(request.tenant_id, chat_session.agent_id), - memory_context, - conversation_context, - ) - finalize_state = self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - prepared.active_skill, - router_decision, - step_result, - tool_result, - ) - if ( - not turn_followup_frames - and finalize_state == "completed" - and request.interaction_mode == "scheduled_task" - ): - continuation = self._try_continue_pending_after_completion( - request, - chat_session, - prepared.model_config, - self._list_published_skills(request.tenant_id, chat_session.agent_id), - self._tools_with_general_skills( - request.tenant_id, - self._list_enabled_tools(request.tenant_id, chat_session.agent_id), - chat_session.agent_id, - ), - self._get_persona_prompt(request.tenant_id, chat_session.agent_id), - memory_context, - conversation_context, - reply, - ) - if continuation: - reply = "\n\n".join( - part - for part in (reply.strip(), continuation.reply.strip()) - if part - ) - router_decision = continuation.router_decision - step_result = continuation.step_result - tool_result = continuation.tool_result - elif chat_session.pending_tasks_json: - self.events.record( - request.tenant_id, - chat_session.id, - "pending_tasks_waiting", - {"pending_tasks": chat_session.pending_tasks_json or []}, - ) - + return engine.run(request) except (HarnessTurnConflict, HarnessSessionBusy) as exc: - if harness_v2_engine is not None: - chat_session = harness_v2_engine.session - harness_v2_engine.close() + chat_session = engine.session self.db.rollback() chat_session = chat_session or self._get_or_create_session(request) return ChatTurnResponse( @@ -428,56 +152,40 @@ def handle_turn(self, request: ChatTurnRequest) -> ChatTurnResponse: "请等待原请求完成,或为新请求使用新的 client_turn_id。", ), session_id=chat_session.id, - step_result=StepAgentResult(action="reply"), + step_result=step_result, session_state=public_session(chat_session), ) except HarnessExecutionCancelled: - if harness_v2_engine is not None: - chat_session = harness_v2_engine.session - prepared_user_message_id = harness_v2_engine.user_message_id - try: - harness_v2_engine.mark_cancelled() - finally: - harness_v2_engine.close() + chat_session = engine.session + user_message_id = engine.user_message_id + engine.mark_cancelled() chat_session = chat_session or self._get_or_create_session(request) - if prepared_user_message_id: + if user_message_id: self._persist_cancelled_assistant_message( request.tenant_id, chat_session, - prepared_user_message_id, + user_message_id, request.client_turn_id, ) self.db.commit() - for turn_id in ( - prepared_user_message_id, - request.client_turn_id, - ): + for turn_id in (user_message_id, request.client_turn_id): if turn_id: clear_chat_turn_cancelled(chat_session.id, turn_id) return ChatTurnResponse( reply=CANCELLED_ASSISTANT_REPLY, session_id=chat_session.id, - step_result=StepAgentResult(action="reply"), + step_result=step_result, session_state=public_session(chat_session), ) except AgentLoopPreconditionError as exc: - if harness_v2_engine is not None: - chat_session = harness_v2_engine.session - prepared_user_message_id = harness_v2_engine.user_message_id - try: - harness_v2_engine.mark_interrupted(exc.code, exc.message) - finally: - harness_v2_engine.close() + chat_session = engine.session + engine.mark_interrupted(exc.code, exc.message) chat_session = chat_session or self._get_or_create_session(request) return self._finish_with_error(chat_session, exc.code, exc.message) except LLMError as exc: - if harness_v2_engine is not None: - chat_session = harness_v2_engine.session - prepared_user_message_id = harness_v2_engine.user_message_id - try: - harness_v2_engine.mark_interrupted("LLM_ERROR", str(exc)) - finally: - harness_v2_engine.close() + chat_session = engine.session + user_message_id = engine.user_message_id + engine.mark_interrupted("LLM_ERROR", str(exc)) chat_session = chat_session or self._get_or_create_session(request) self.events.record( request.tenant_id, @@ -489,3814 +197,448 @@ def handle_turn(self, request: ChatTurnRequest) -> ChatTurnResponse: "模型调用失败", exc, "LLM_ERROR", model_failure_suggestion(exc) ) except Exception as exc: - if harness_v2_engine is not None: - chat_session = harness_v2_engine.session - prepared_user_message_id = harness_v2_engine.user_message_id - try: - harness_v2_engine.mark_interrupted( - "AGENT_LOOP_ERROR", - str(exc), - ) - finally: - harness_v2_engine.close() + chat_session = engine.session + user_message_id = engine.user_message_id + engine.mark_interrupted("HARNESS_V2_ERROR", str(exc)) chat_session = chat_session or self._get_or_create_session(request) self.events.record( request.tenant_id, chat_session.id, "error_occurred", - {"code": "AGENT_LOOP_ERROR", "message": str(exc)}, + {"code": "HARNESS_V2_ERROR", "message": str(exc)}, ) reply = format_runtime_failure_reply( - "Agent Loop 出错", + "Harness v2 执行出错", exc, - "AGENT_LOOP_ERROR", + "HARNESS_V2_ERROR", "请查看执行记录或服务日志定位具体原因。", ) + finally: + engine.close() - if not chat_session: - chat_session = self._get_or_create_session(request) reply = self._finalize_turn( chat_session, request.tenant_id, reply, step_result, request.message, - user_message_id=prepared_user_message_id, + user_message_id=user_message_id, ) self.db.commit() self.db.refresh(chat_session) - if memory_model_config: - self._enqueue_memory_capture( - request, - chat_session, - step_result, - tool_result, - memory_model_config, - ) return ChatTurnResponse( reply=reply, session_id=chat_session.id, - router_decision=router_decision, step_result=step_result, - tool_result=tool_result, session_state=public_session(chat_session), ) - def _try_handle_general_skill_after_scene_router( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - router_decision: RouterDecision, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - user_message_id: str | None = None, - capability: tuple[GeneralSkill | None, GeneralSkillSelection] | None = None, - ) -> ChatTurnResponse | None: - if not self._scene_router_deferred_to_general(router_decision): - return None - capability = capability or self._select_general_capability( - request.message, - model_config, - chat_session.agent_id, - conversation_context, - memory_context, + def handle_turn_stream(self, request: ChatTurnRequest) -> Iterator[dict[str, object]]: + yield from self._handle_turn_stream_v2(request) + + def _handle_turn_stream_v2(self, request: ChatTurnRequest) -> Iterator[dict[str, object]]: + session_request = _with_recoverable_first_session(request) + existing_session = ( + self.db.get(ChatSession, session_request.session_id) + if session_request.session_id + else None ) - skill, selection = capability - if skill is None: - return None - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_intent_checked", - self._turn_payload( + chat_session = get_or_create_harness_session( + self, + session_request, + ) + created_session = existing_session is None + scoped_request = request.model_copy(update={"session_id": chat_session.id}) + initial_turn_id = str(request.client_turn_id or "").strip() or None + if created_session: + yield self._stream_event( + "session_created", + chat_session, { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - "confidence": selection.confidence, - "reason": selection.reason, - "scene_router_decision": router_decision.model_dump(mode="json"), + "sessionId": chat_session.id, + "turn_id": initial_turn_id, + "client_turn_id": request.client_turn_id, + "execution_engine": "harness_v2", }, - user_message_id, - ), - ) - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_selected", + ) + yield self._stream_event( + "user_message_received", + chat_session, self._turn_payload( { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - "confidence": selection.confidence, - "reason": selection.reason, - "scene_router_decision": router_decision.model_dump(mode="json"), + "sessionId": chat_session.id, + "client_turn_id": request.client_turn_id, + "execution_engine": "harness_v2", }, - user_message_id, - ), - ) - skill_snapshot = self._general_skill_runtime_snapshot( - request, chat_session, skill, user_message_id - ) - run_response = self._run_general_skill_operation( - skill_snapshot, - request.message, - model_config, - request.user_id, - selection.operation, - conversation_context=conversation_context, - memory_context=memory_context, - ) - self._record_general_skill_run_events( - request.tenant_id, chat_session, run_response, user_message_id - ) - step_result, tool_result = self._general_skill_agent_outputs(run_response) - knowledge_step = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - router_decision, - selection, - ) - self._merge_capability_knowledge(step_result, knowledge_step) - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - reply = self._generate_reply_segment( - request.message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - model_config, - self._get_persona_prompt(request.tenant_id, chat_session.agent_id), - memory_context or [], - ( - conversation_context - if conversation_context is not None - else self._conversation_context(chat_session) + initial_turn_id, ), ) - reply = self._finalize_turn( - chat_session, - request.tenant_id, - reply, - step_result, - request.message, - user_message_id=user_message_id, - ) - self.db.commit() - self.db.refresh(chat_session) - self._enqueue_memory_capture( - request, + yield self._stream_status( chat_session, - step_result, - tool_result, - model_config, - ) - return ChatTurnResponse( - reply=reply, - session_id=chat_session.id, - router_decision=router_decision, - step_result=step_result, - tool_result=tool_result, - session_state=public_session(chat_session), - ) - - def _general_skill_agent_outputs( - self, run_response: GeneralSkillRunResponse - ) -> tuple[StepAgentResult, ToolResult]: - success = ( - bool(run_response.structured_result.get("success", True)) - and not run_response.stderr.strip() - ) - data = { - "skill_slug": run_response.skill_slug, - "operation": run_response.operation, - "reply": run_response.reply, - "structured_result": run_response.structured_result, - "stdout": run_response.stdout, - "stderr": run_response.stderr, - } - tool_result = ToolResult( - tool_name=f"{GENERAL_SKILL_TOOL_PREFIX}{run_response.skill_slug}", - success=success, - data=data if success else None, - error=None - if success - else ToolError( - code="GENERAL_SKILL_FAILED", message=run_response.stderr or run_response.reply - ), - ) - step_result = StepAgentResult( - reply=run_response.reply, - is_step_completed=success, - tool_call=None, + "planning", + "正在规划本轮任务", + {"execution_engine": "harness_v2"}, + user_message_id=initial_turn_id, ) - return step_result, tool_result - - def _run_general_skill_operation( - self, - skill: GeneralSkill, - query: str, - model_config: ModelConfig, - user_id: str, - operation: str = "execute", - *, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - event_sink: Callable[[dict[str, Any]], None] | None = None, - ) -> GeneralSkillRunResponse: - try: - if operation == "read": - response = self.general_skill_reader.read( - skill, - query, - model_config, - conversation_context=conversation_context, - memory_context=memory_context, + response = self.handle_turn(scoped_request) + chat_session = self.db.get(ChatSession, response.session_id) + if chat_session is None: + return + user_message = None + client_turn_id = str(request.client_turn_id or "").strip() + if client_turn_id: + receipt = self.db.exec( + select(HarnessTurnRecord).where( + HarnessTurnRecord.tenant_id == request.tenant_id, + HarnessTurnRecord.session_id == response.session_id, + HarnessTurnRecord.client_turn_id == client_turn_id, ) - if event_sink: - for item in response.execution_trace: - event_sink(item) - return response - ui_config = self.db.get(UIConfig, skill.tenant_id) - sandbox_mode = parse_network_policy( - getattr(ui_config, "sandbox_network_mode", None) if ui_config else None - ) - sandbox_domains = tuple( - str(item).strip() - for item in (getattr(ui_config, "sandbox_allowed_domains", []) if ui_config else []) - if str(item).strip() - ) - run_kwargs = { - "event_sink": event_sink, - "conversation_context": conversation_context, - "memory_context": memory_context, - "sandbox_network_mode": sandbox_mode, - "sandbox_allowed_domains": sandbox_domains, - } - if "sandbox_network_mode" not in inspect.signature(self.general_skill_runner.run).parameters: - run_kwargs.pop("sandbox_network_mode") - run_kwargs.pop("sandbox_allowed_domains") - return self.general_skill_runner.run( - skill, - query, - model_config, - user_id, - **run_kwargs, + ).first() + if receipt is not None and receipt.user_message_id: + candidate = self.db.get(Message, receipt.user_message_id) + if ( + candidate is not None + and candidate.tenant_id == request.tenant_id + and candidate.session_id == response.session_id + and candidate.role == "user" + ): + user_message = candidate + if user_message is None and not client_turn_id: + user_message = self.db.exec( + select(Message) + .where( + Message.tenant_id == request.tenant_id, + Message.session_id == response.session_id, + Message.role == "user", + ) + .order_by(Message.created_at.desc()) + ).first() + user_message_id = user_message.id if user_message else None + if response.reply == CANCELLED_ASSISTANT_REPLY: + yield self._stream_event( + "stream_cancelled", + chat_session, + self._turn_payload( + { + "phase": "cancelled", + "text": CANCELLED_ASSISTANT_REPLY, + "client_turn_id": request.client_turn_id, + "execution_engine": "harness_v2", + }, + user_message_id or initial_turn_id, + ), ) - except HarnessExecutionError as exc: - # Sandbox startup/policy failures are infrastructure failures, not - # generated-code failures. Do not ask the reflection loop to retry - # the same code; return a structured, non-retryable tool result so - # the response model can explain the setup issue to the user. - code = str(exc.error.code or "SANDBOX_EXECUTION_FAILED") - message = str(exc.error.message or exc) - response = GeneralSkillRunResponse( - skill_slug=skill.slug, - operation=operation, - execution_trace=[ - {"phase": "sandbox_failed", "message": message, "error": code} - ], - generated_code="", - stdout="", - stderr=message, - structured_result={ - "success": False, - "error": code, - "message": message, - "retryable": False, - "infrastructure_failure": True, - }, - reply="当前安全运行环境不可用,暂时无法执行该技能。", + return + for chunk in self.response_generator.chunk_text(response.reply): + event = self._stream_event( + "stream_delta", + chat_session, + self._turn_payload( + { + "content": chunk, + "execution_engine": "harness_v2", + }, + user_message_id, + ), ) - if event_sink: - event_sink(response.execution_trace[0]) - return response - - @staticmethod - def _merge_capability_knowledge( - step_result: StepAgentResult, - knowledge_step: StepAgentResult, - ) -> None: - if knowledge_step.knowledge_query is not None: - step_result.knowledge_query = knowledge_step.knowledge_query - if knowledge_step.knowledge_results: - step_result.knowledge_results = knowledge_step.knowledge_results - - def _scene_router_deferred_to_general(self, router_decision: RouterDecision) -> bool: - if router_decision.selected_task_id: - return False - if ( - router_decision.task_frames - or - router_decision.pending_tasks - or router_decision.created_tasks - or router_decision.task_updates - ): - return False - return router_decision.decision in { - "answer_only", - "clarify", - } - - def _should_run_step_agent( - self, router_decision: RouterDecision, active_skill: Skill | None - ) -> bool: - if active_skill is None: - return False - return router_decision.decision not in { - "answer_only", - "clarify", - "create_pending", - "update_pending", - "complete_task", - "handoff_human", - } - - def _stream_general_skill_response( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - selected_general_skill: tuple[GeneralSkill, GeneralSkillSelection], - router_decision: RouterDecision | None = None, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - persona_prompt: str | None = None, - user_message_id: str | None = None, - is_cancelled: Callable[[], bool] | None = None, - ) -> Iterator[dict[str, object]]: - skill, selection = selected_general_skill - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_intent_checked", - self._turn_payload( - { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - "confidence": selection.confidence, - "reason": selection.reason, - "scene_router_decision": router_decision.model_dump(mode="json") - if router_decision - else None, - }, - user_message_id, - ), + self.db.commit() + yield event + end_event = self._stream_event( + "stream_end", + chat_session, + self._turn_payload({"execution_engine": "harness_v2"}, user_message_id), ) - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_selected", + self.db.commit() + yield end_event + yield self._stream_event( + "complete", + chat_session, self._turn_payload( { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - "confidence": selection.confidence, - "reason": selection.reason, - "scene_router_decision": router_decision.model_dump(mode="json") - if router_decision - else None, + **response.model_dump(mode="json"), + "execution_engine": "harness_v2", }, user_message_id, ), ) - yield self._stream_status( - chat_session, - "general_skill_intent", - "正在判断意图", - {"skill_slug": skill.slug, "skill_name": skill.name, "reason": selection.reason}, - user_message_id=user_message_id, - ) - yield self._stream_event( - "general_skill_trace", - chat_session, - self._turn_payload( - { - "phase": "intent_checked", - "message": "判断意图", - "skill_slug": skill.slug, - "skill_name": skill.name, - "reason": selection.reason, - "confidence": selection.confidence, - }, - user_message_id, - ), - ) - yield self._stream_status( - chat_session, - "general_skill_routing", - "正在选择通用技能", - {"skill_slug": skill.slug, "skill_name": skill.name}, - user_message_id=user_message_id, - ) - yield self._stream_event( - "general_skill_state", - chat_session, - self._turn_payload( - {"skillSlug": skill.slug, "skillName": skill.name, "state": "selected"}, - user_message_id, - ), - ) - operation_text = "正在阅读通用技能" if selection.operation == "read" else "正在运行通用技能" - yield self._stream_status( - chat_session, - "general_skill_running", - operation_text, - { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - }, - user_message_id=user_message_id, - ) - general_skill_events: queue.Queue[tuple[str, Any] | None] = queue.Queue() - skill_snapshot = self._general_skill_runtime_snapshot( - request, chat_session, skill, user_message_id - ) - model_snapshot = model_config - - def general_skill_sink(trace_item: dict[str, Any]) -> None: - general_skill_events.put(("trace", trace_item)) - - def general_skill_worker() -> None: - try: - response = self._run_general_skill_operation( - skill_snapshot, - request.message, - model_snapshot, - request.user_id, - selection.operation, - event_sink=general_skill_sink, - conversation_context=conversation_context, - memory_context=memory_context, - ) - general_skill_events.put(("complete", response)) - except Exception as exc: # pragma: no cover - defensive stream boundary - general_skill_events.put(("error", exc)) - finally: - general_skill_events.put(None) - - threading.Thread(target=general_skill_worker, daemon=True).start() - run_response: GeneralSkillRunResponse | None = None - streamed_trace_count = 0 - while True: - if is_cancelled and is_cancelled(): - return - try: - queued = general_skill_events.get(timeout=0.5) - except queue.Empty: - continue - if queued is None: - break - event_name, payload = queued - if event_name == "trace": - if is_cancelled and is_cancelled(): - return - streamed_trace_count += 1 - yield self._stream_event( - "general_skill_trace", - chat_session, - self._turn_payload(payload, user_message_id), - ) - elif event_name == "complete": - run_response = payload - elif event_name == "error": - raise payload - - if run_response is None: - raise LLMError("General skill stream ended without a result") - if is_cancelled and is_cancelled(): - return - self._record_general_skill_run_events( - request.tenant_id, - chat_session, - run_response, - user_message_id, - include_trace=streamed_trace_count == 0, - ) - if is_cancelled and is_cancelled(): - return - step_result, tool_result = self._general_skill_agent_outputs(run_response) - resolved_router_decision = router_decision or RouterDecision( - decision="answer_only", user_intent="通用技能执行结果回复" - ) - knowledge_stream_events: list[tuple[str, dict[str, object]]] = [] - knowledge_step = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - resolved_router_decision, - selection, - stream_events=knowledge_stream_events, - ) - self._merge_capability_knowledge(step_result, knowledge_step) - for event_name, payload in knowledge_stream_events: - yield self._stream_event( - event_name, - chat_session, - self._turn_payload(payload, user_message_id), - ) - yield self._stream_status( - chat_session, "responding", "正在生成回复", user_message_id=user_message_id - ) - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - reply = "" - for chunk in self._generate_reply_stream_segment( - request.message, - chat_session, - active_skill, - resolved_router_decision, - step_result, - tool_result, - model_config, - persona_prompt - if persona_prompt is not None - else self._get_persona_prompt(request.tenant_id, chat_session.agent_id), - memory_context or [], - ( - conversation_context - if conversation_context is not None - else self._conversation_context(chat_session) - ), - ): - reply += chunk - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - if not reply.strip(): - reply = run_response.reply - for chunk in self.response_generator.chunk_text(reply): - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - if is_cancelled and is_cancelled(): - return - repaired_reply = self._restore_reply_atomic_references(reply, step_result) - if repaired_reply != reply: - reply = repaired_reply - yield self._stream_event( - "stream_replace", - chat_session, - self._turn_payload({"content": reply}, user_message_id), - ) - yield self._stream_event( - "stream_end", chat_session, self._turn_payload({}, user_message_id) - ) - if is_cancelled and is_cancelled(): - return - reply = self._finalize_turn( - chat_session, - request.tenant_id, - reply, - step_result, - request.message, - user_message_id=user_message_id, - ) - self.db.commit() - self.db.refresh(chat_session) - self._enqueue_memory_capture( - request, - chat_session, - step_result, - tool_result, - model_config, - ) - result = ChatTurnResponse( - reply=reply, - session_id=chat_session.id, - router_decision=router_decision, - step_result=step_result, - tool_result=tool_result, - session_state=public_session(chat_session), - ) - yield self._stream_event( - "complete", - chat_session, - self._turn_payload(result.model_dump(mode="json"), user_message_id), - ) - - def _stream_continue_pending_after_completion( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - skills: list[Skill], - tools: list[Any], - persona_prompt: str | None, - memory_context: list[dict[str, object]], - conversation_context: dict[str, object], - completed_reply: str, - completed_skill_ids_this_turn: set[str] | None = None, - user_message_id: str | None = None, - turn_task_frames: list[PendingTask] | None = None, - ) -> Iterator[dict[str, object]]: - remaining_turn_frames = list(turn_task_frames or []) - uses_turn_frames = turn_task_frames is not None - if uses_turn_frames and not remaining_turn_frames: - return None - if not uses_turn_frames and not chat_session.pending_tasks_json: - return None - max_actions = max(1, self._get_agent_loop_max_actions(request.tenant_id)) - executed_actions = 0 - replies: list[str] = [] - task_results: list[dict[str, object]] = [] - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - active_skill: Skill | None = None - router_decision = RouterDecision(decision="answer_only", reason="No pending task selected") - step_result = StepAgentResult() - tool_result: ToolResult | None = None - - for queue_round in range(max_actions): - if uses_turn_frames: - if not remaining_turn_frames: - break - turn_frame = remaining_turn_frames.pop(0) - task_id = turn_frame.task_id or f"turn_task_{queue_round + 1}" - else: - if not chat_session.pending_tasks_json: - break - task_id = self._next_pending_task_id(chat_session) - if not task_id: - break - yield self._stream_status( - chat_session, - "routing", - "正在继续后续任务", - {"queue_round": queue_round + 1}, - user_message_id=user_message_id, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "router_execution_order_advanced", - {"task_id": task_id, "queue_round": queue_round + 1}, - ) - - for task_id in [task_id]: - if executed_actions >= max_actions: - break - router_decision = ( - self._router_decision_from_turn_task_frame(turn_frame) - if uses_turn_frames - else self._router_decision_from_task_frame( - chat_session, - task_id, - "按 Router 已确定的任务顺序继续执行。", - ) - ) - if not router_decision: - continue - yield self._stream_event( - "router_decision", - chat_session, - self._turn_payload(router_decision.model_dump(mode="json"), user_message_id), - ) - - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - self.runtime.apply_decision(chat_session, router_decision) - state_pruned = self._drop_unavailable_skill_state( - request.tenant_id, chat_session, skills - ) - if self._should_record_runtime_event_after_prune( - router_decision, chat_session, skills, state_pruned - ): - self._record_runtime_event( - request.tenant_id, chat_session, before_skill, before_step, router_decision - ) - self.db.commit() - self.db.refresh(chat_session) - - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - if not self._should_run_step_agent(router_decision, active_skill): - continue - yield self._stream_event( - "skill_state", - chat_session, - self._skill_state_payload( - chat_session, - skills, - self._runtime_stream_context( - router_decision, before_skill, before_step, chat_session - ), - user_message_id=user_message_id, - ), - ) - yield self._stream_status( - chat_session, - "stepping", - "正在思考", - { - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - }, - user_message_id=user_message_id, - ) - repair_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result = self._run_step_agent_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - repair_stream_events, - ) - yield self._stream_event( - "step_result", - chat_session, - self._turn_payload(step_result.model_dump(mode="json"), user_message_id), - ) - self.db.commit() - self.db.refresh(chat_session) - for event_name, payload in repair_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - if step_result.knowledge_query: - knowledge_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result = self._execute_knowledge_query_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - knowledge_stream_events, - ) - self.db.commit() - self.db.refresh(chat_session) - for event_name, payload in knowledge_stream_events: - yield self._stream_event( - event_name, - chat_session, - self._turn_payload(payload, user_message_id), - ) - - tool_result = None - if step_result.tool_call: - tool_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result, tool_result = self._execute_tool_action_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - tool_stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - for event_name, payload in tool_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - reflection_stream_events: list[tuple[str, dict[str, object]]] = [] - reflection_max_rounds = self._get_reflection_max_rounds(request.tenant_id) - if reflection_max_rounds > 0 and self._should_try_reflection( - router_decision, step_result, tool_result - ): - yield self._stream_status( - chat_session, - "reflecting", - "正在反思", - {"reflection_round": 1, "reflection_max_rounds": reflection_max_rounds}, - user_message_id=user_message_id, - ) - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._run_reflection_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - reflection_max_rounds, - conversation_context, - reflection_stream_events, - completed_skill_ids_this_turn, - memory_context=memory_context, - ) - for event_name, payload in reflection_stream_events: - yield self._stream_event(event_name, chat_session, payload) - - graph_stream_events: list[tuple[str, dict[str, object]]] = [] - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._auto_progress_skill_graph( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - memory_context, - conversation_context, - graph_stream_events, - completed_skill_ids_this_turn, - ) - for event_name, payload in graph_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - task_results.append( - self._task_response_context( - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - ) - draft = self._task_response_draft(step_result) - if draft: - replies, _ = self._merge_queued_reply_segment(replies, draft) - executed_actions += 1 - finalize_state = self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - if finalize_state == "completed" and active_skill: - completed_skill_ids_this_turn.add(active_skill.skill_id) - if finalize_state == "handoff": - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - if finalize_state == "continued": - if uses_turn_frames and remaining_turn_frames: - if chat_session.active_skill_id: - self.runtime.suspend_current_skill(chat_session, enqueue=True) - self.db.commit() - self.db.refresh(chat_session) - continue - if self._should_attempt_queued_task_followup( - request, - chat_session, - skills, - "\n\n".join([completed_reply, *replies]).strip(), - queue_round + 1, - ): - if active_skill: - completed_skill_ids_this_turn.add(active_skill.skill_id) - continue - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "pending_tasks_waiting", - { - "pending_tasks": chat_session.pending_tasks_json or [], - "round": queue_round + 1, - }, - ) - if executed_actions >= max_actions: - break - - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - - def handle_turn_stream(self, request: ChatTurnRequest) -> Iterator[dict[str, object]]: - if self._uses_harness_v2(request): - yield from self._handle_turn_stream_v2(request) - return - LegacyKnowledgeAction.reset_turn() - router_decision: RouterDecision | None = None - step_result = StepAgentResult() - tool_result: ToolResult | None = None - chat_session: ChatSession | None = None - reply = "" - memory_model_config: ModelConfig | None = None - turn_finalized = False - user_message_id: str | None = None - - def record_current_turn_cancelled(client_turn_id: str | None = None) -> bool: - nonlocal turn_finalized - if not chat_session or not user_message_id: - return False - if turn_finalized: - return False - normalized_client_turn_id = (client_turn_id or request.client_turn_id or "").strip() - self.db.rollback() - existing_cancel = self.db.exec( - select(AgentEvent) - .where( - AgentEvent.tenant_id == request.tenant_id, - AgentEvent.session_id == chat_session.id, - AgentEvent.event_type == "stream_cancelled", - ) - .order_by(AgentEvent.created_at.desc()) - ).all() - for event in existing_cancel: - payload = event.payload_json or {} - event_turn_ids = { - str(payload.get("turn_id") or "").strip(), - str(payload.get("user_message_id") or "").strip(), - str(payload.get("message_id") or "").strip(), - str(payload.get("client_turn_id") or "").strip(), - } - matches_server_turn = user_message_id in event_turn_ids - matches_client_turn = bool( - normalized_client_turn_id and normalized_client_turn_id in event_turn_ids - ) - if matches_server_turn or matches_client_turn: - self._persist_cancelled_assistant_message( - request.tenant_id, - chat_session, - user_message_id, - normalized_client_turn_id, - ) - clear_chat_turn_cancelled(chat_session.id, user_message_id) - if normalized_client_turn_id: - clear_chat_turn_cancelled(chat_session.id, normalized_client_turn_id) - turn_finalized = True - return True - self.events.record( - request.tenant_id, - chat_session.id, - "stream_cancelled", - self._turn_payload( - { - "phase": "cancelled", - "text": "已停止生成", - "client_turn_id": normalized_client_turn_id or None, - }, - user_message_id, - ), - ) - self._persist_cancelled_assistant_message( - request.tenant_id, - chat_session, - user_message_id, - normalized_client_turn_id, - ) - self.db.commit() - clear_chat_turn_cancelled(chat_session.id, user_message_id) - if normalized_client_turn_id: - clear_chat_turn_cancelled(chat_session.id, normalized_client_turn_id) - turn_finalized = True - return True - - def mark_current_turn_cancelled() -> bool: - if not chat_session or not user_message_id: - return False - client_turn_id = (request.client_turn_id or "").strip() - server_cancelled = is_chat_turn_cancelled(chat_session.id, user_message_id) - client_cancelled = bool( - client_turn_id and is_chat_turn_cancelled(chat_session.id, client_turn_id) - ) - if not server_cancelled and not client_cancelled: - return False - return record_current_turn_cancelled(client_turn_id) - - def finalize_turn_once( - target_session: ChatSession, - final_reply: str, - final_step_result: StepAgentResult | None = None, - final_source_message: str | None = None, - ) -> str: - nonlocal turn_finalized - if turn_finalized: - return final_reply - finalized_reply = self._finalize_turn( - target_session, - request.tenant_id, - final_reply, - final_step_result, - final_source_message, - user_message_id=user_message_id, - ) - turn_finalized = True - return finalized_reply - - def recover_chat_session_after_exception() -> ChatSession: - nonlocal chat_session - session_id = str( - (chat_session.id if chat_session else request.session_id) or "" - ).strip() - self.db.rollback() - recovered = self.db.get(ChatSession, session_id) if session_id else None - if recovered is None: - recovered = self._get_or_create_session(request) - chat_session = recovered - return recovered - - def exception_payload(code: str, message: str) -> dict[str, object]: - payload: dict[str, object] = { - "code": code, - "message": message, - "client_turn_id": request.client_turn_id or None, - "error_traceback": traceback.format_exc()[-ERROR_TRACEBACK_CHAR_LIMIT:], - } - return self._turn_payload(payload, user_message_id) - - def stream_failure_response( - title: str, - error: object, - code: str, - suggestion: str, - message: str | None = None, - ) -> Iterator[dict[str, object]]: - nonlocal reply - target_session = recover_chat_session_after_exception() - if mark_current_turn_cancelled(): - return - error_message = message if message is not None else str(error) - reply = format_runtime_failure_reply(title, error_message, code, suggestion) - payload = exception_payload(code, error_message) - self.events.record(request.tenant_id, target_session.id, "error_occurred", payload) - self.db.commit() - yield self._stream_status( - target_session, - "error", - reply, - {"code": code, "message": error_message}, - user_message_id=user_message_id, - ) - yield self._stream_event("error_occurred", target_session, payload) - for chunk in self.response_generator.chunk_text(reply): - yield self._stream_event( - "stream_delta", - target_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - yield self._stream_event( - "stream_end", target_session, self._turn_payload({}, user_message_id) - ) - finalize_turn_once(target_session, reply, step_result, request.message) - self.db.commit() - self.db.refresh(target_session) - - try: - chat_session = self._get_or_create_session(request) - self._mark_session_running(chat_session) - yield self._stream_event( - "session_created", - chat_session, - {"newSessionId": chat_session.id, "sessionId": chat_session.id}, - ) - user_message = self._append_message( - request.tenant_id, - chat_session.id, - "user", - request.message, - metadata=self._user_message_metadata(request), - ) - user_message_id = user_message.id - bind_event_turn = getattr(self.events, "bind_turn", None) - if callable(bind_event_turn): - bind_event_turn(user_message.id, request.client_turn_id) - self.events.record( - request.tenant_id, - chat_session.id, - "user_message_received", - { - "message_id": user_message.id, - "client_turn_id": request.client_turn_id, - "message": request.message, - "channel": request.channel, - "user_id": request.user_id, - }, - ) - yield self._stream_event( - "user_message_received", - chat_session, - self._turn_payload( - { - "message_id": user_message.id, - "client_turn_id": request.client_turn_id, - "message": request.message, - "channel": request.channel, - "user_id": request.user_id, - }, - user_message.id, - ), - ) - self.db.commit() - self.db.refresh(chat_session) - self.db.refresh(user_message) - model_config = self._get_request_model(request, chat_session.agent_id) - if not model_config: - raise AgentLoopPreconditionError("missing_model_config", "没有默认模型配置。") - memory_model_config = model_config - skills = self._list_published_skills(request.tenant_id, chat_session.agent_id) - tools = self._tools_with_general_skills( - request.tenant_id, - self._list_enabled_tools(request.tenant_id, chat_session.agent_id), - chat_session.agent_id, - ) - persona_prompt = self._get_persona_prompt(request.tenant_id, chat_session.agent_id) - self._drop_unavailable_skill_state(request.tenant_id, chat_session, skills) - if not skills: - no_skill_context = self._conversation_context( - chat_session, model_config=model_config - ) - if self._context_compacted_now(no_skill_context): - yield self._stream_status( - chat_session, - "preparing", - "正在整理上下文", - user_message_id=user_message_id, - ) - yield self._stream_status( - chat_session, "routing", "正在判断用户意图", user_message_id=user_message_id - ) - capability = self._select_general_capability( - request.message, - model_config, - chat_session.agent_id, - no_skill_context, - [], - ) - if capability[0] is not None: - yield from self._stream_general_skill_response( - request, - chat_session, - model_config, - (capability[0], capability[1]), - None, - [], - no_skill_context, - persona_prompt, - user_message.id, - mark_current_turn_cancelled, - ) - return - router_decision = RouterDecision( - decision="answer_only", - reason="No published scene skills are available; answer as chat.", - ) - yield self._stream_event( - "router_decision", - chat_session, - self._turn_payload(router_decision.model_dump(mode="json"), user_message_id), - ) - knowledge_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - router_decision, - capability[1], - stream_events=knowledge_stream_events, - ) - for event_name, payload in knowledge_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - yield self._stream_status( - chat_session, "responding", "正在生成回复", user_message_id=user_message_id - ) - reply = "" - for chunk in self._generate_reply_stream_segment( - request.message, - chat_session, - None, - router_decision, - step_result, - None, - model_config, - persona_prompt, - [], - no_skill_context, - ): - reply += chunk - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - if mark_current_turn_cancelled(): - return - yield self._stream_event( - "stream_end", chat_session, self._turn_payload({}, user_message_id) - ) - if mark_current_turn_cancelled(): - return - finalize_turn_once(chat_session, reply, step_result, request.message) - self.db.commit() - self.db.refresh(chat_session) - result = ChatTurnResponse( - reply=reply, - session_id=chat_session.id, - router_decision=router_decision, - step_result=step_result, - session_state=public_session(chat_session), - ) - yield self._stream_event( - "complete", - chat_session, - self._turn_payload(result.model_dump(mode="json"), user_message_id), - ) - return - self._finish_stale_completed_skill(request.tenant_id, chat_session, skills) - memory_context = [ - memory_read(row) - for row in self.memory.context_memories( - request.tenant_id, - request.user_id, - agent_id=chat_session.agent_id, - ) - ] - if memory_context: - self.events.record( - request.tenant_id, - chat_session.id, - "memory_recalled", - {"memories": memory_context}, - ) - self.db.commit() - self.db.refresh(chat_session) - conversation_context = self._conversation_context(chat_session, model_config=model_config) - if self._context_compacted_now(conversation_context): - yield self._stream_status( - chat_session, - "preparing", - "正在整理上下文", - user_message_id=user_message_id, - ) - - yield self._stream_status( - chat_session, "routing", "正在判断用户意图", user_message_id=user_message_id - ) - router_decision = self.router.decide( - request.message, - chat_session, - skills, - model_config, - conversation_context, - memory_context, - ) - turn_followup_frames = self._turn_followup_task_frames(router_decision) - hydrated_slots = self._hydrate_router_decision_from_context( - chat_session, router_decision, skills, memory_context - ) - if hydrated_slots: - self.events.record( - request.tenant_id, - chat_session.id, - "router_slots_hydrated", - hydrated_slots, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "router_decision_created", - self._turn_payload(router_decision.model_dump(), user_message_id), - ) - yield self._stream_event( - "router_decision", - chat_session, - self._turn_payload(router_decision.model_dump(mode="json"), user_message_id), - ) - capability_selection: GeneralSkillSelection | None = None - if self._scene_router_deferred_to_general(router_decision): - capability = self._select_general_capability( - request.message, - model_config, - chat_session.agent_id, - conversation_context, - memory_context, - ) - capability_selection = capability[1] - if capability[0] is not None: - yield from self._stream_general_skill_response( - request, - chat_session, - model_config, - (capability[0], capability[1]), - router_decision, - memory_context, - conversation_context, - persona_prompt, - user_message.id, - mark_current_turn_cancelled, - ) - return - - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - self.runtime.apply_decision(chat_session, router_decision) - state_pruned = self._drop_unavailable_skill_state( - request.tenant_id, chat_session, skills - ) - if self._should_record_runtime_event_after_prune( - router_decision, chat_session, skills, state_pruned - ): - self._record_runtime_event( - request.tenant_id, chat_session, before_skill, before_step, router_decision - ) - self.db.commit() - self.db.refresh(chat_session) - - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - if not self._should_run_step_agent(router_decision, active_skill): - knowledge_stream_events = [] - auto_step_result = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - router_decision, - capability_selection, - stream_events=knowledge_stream_events, - ) - if auto_step_result.knowledge_query: - step_result = auto_step_result - for event_name, payload in knowledge_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - yield self._stream_status( - chat_session, "responding", "正在生成回复", user_message_id=user_message_id - ) - for chunk in self._generate_reply_stream_segment( - request.message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - model_config, - persona_prompt, - memory_context, - conversation_context, - ): - reply += chunk - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - yield self._stream_event( - "stream_end", chat_session, self._turn_payload({}, user_message_id) - ) - finalize_turn_once(chat_session, reply, step_result, request.message) - self.db.commit() - self.db.refresh(chat_session) - result = ChatTurnResponse( - reply=reply, - session_id=chat_session.id, - router_decision=router_decision, - step_result=step_result, - tool_result=tool_result, - session_state=public_session(chat_session), - ) - yield self._stream_event( - "complete", - chat_session, - self._turn_payload(result.model_dump(mode="json"), user_message_id), - ) - return - yield self._stream_event( - "skill_state", - chat_session, - self._skill_state_payload( - chat_session, - skills, - self._runtime_stream_context( - router_decision, before_skill, before_step, chat_session - ), - user_message_id=user_message_id, - ), - ) - yield self._stream_status( - chat_session, - "stepping", - "正在思考", - { - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - }, - user_message_id=user_message_id, - ) - repair_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result = self._run_step_agent_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - repair_stream_events, - ) - yield self._stream_event( - "step_result", - chat_session, - self._turn_payload(step_result.model_dump(mode="json"), user_message_id), - ) - self.db.commit() - self.db.refresh(chat_session) - for event_name, payload in repair_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - if step_result.knowledge_query: - knowledge_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result = self._execute_knowledge_query_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - knowledge_stream_events, - ) - self.db.commit() - self.db.refresh(chat_session) - for event_name, payload in knowledge_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - if step_result.tool_call: - tool_stream_events: list[tuple[str, dict[str, object]]] = [] - step_result, tool_result = self._execute_tool_action_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - tool_stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - for event_name, payload in tool_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - reflection_stream_events: list[tuple[str, dict[str, object]]] = [] - reflection_max_rounds = self._get_reflection_max_rounds(request.tenant_id) - if reflection_max_rounds > 0 and self._should_try_reflection( - router_decision, step_result, tool_result - ): - yield self._stream_status( - chat_session, - "reflecting", - "正在反思", - {"reflection_round": 1, "reflection_max_rounds": reflection_max_rounds}, - user_message_id=user_message_id, - ) - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._run_reflection_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - reflection_max_rounds, - conversation_context, - reflection_stream_events, - memory_context=memory_context, - ) - for event_name, payload in reflection_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - graph_stream_events: list[tuple[str, dict[str, object]]] = [] - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._auto_progress_skill_graph( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - memory_context, - conversation_context, - graph_stream_events, - ) - for event_name, payload in graph_stream_events: - yield self._stream_event( - event_name, chat_session, self._turn_payload(payload, user_message_id) - ) - - response_task_results: list[dict[str, object]] | None = None - finalize_before_reply = bool(turn_followup_frames) or ( - request.interaction_mode == "scheduled_task" - ) - if finalize_before_reply: - response_task_results = [ - self._task_response_context( - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - ] - finalize_state = self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - else: - finalize_state = "continued" - - if turn_followup_frames and finalize_state != "handoff": - primary_active_skill = active_skill - primary_router_decision = router_decision - primary_step_result = step_result - primary_tool_result = tool_result - paused_primary = None - if finalize_state == "continued" and active_skill: - paused_primary = self.runtime.suspend_current_skill(chat_session) - continuation = None - if turn_followup_frames: - continuation = yield from self._stream_continue_pending_after_completion( - request, - chat_session, - model_config, - skills, - tools, - persona_prompt, - memory_context, - conversation_context, - "", - user_message_id=user_message_id, - turn_task_frames=turn_followup_frames, - ) - if continuation and response_task_results is not None: - response_task_results.extend(continuation.task_results) - if paused_primary: - if chat_session.active_skill_id: - self.runtime.suspend_current_skill(chat_session, enqueue=True) - self.runtime.restore_task_frame(chat_session, paused_primary) - self.db.commit() - self.db.refresh(chat_session) - active_skill = primary_active_skill - router_decision = primary_router_decision - step_result = primary_step_result - tool_result = primary_tool_result - elif continuation: - active_skill = continuation.active_skill - router_decision = continuation.router_decision - step_result = continuation.step_result - tool_result = continuation.tool_result - elif finalize_state == "completed" and request.interaction_mode == "scheduled_task": - continuation = None - if self._next_pending_task_id(chat_session): - continuation = yield from self._stream_continue_pending_after_completion( - request, - chat_session, - model_config, - skills, - tools, - persona_prompt, - memory_context, - conversation_context, - "", - user_message_id=user_message_id, - ) - if continuation: - if response_task_results is not None: - response_task_results.extend(continuation.task_results) - active_skill = continuation.active_skill - router_decision = continuation.router_decision - step_result = continuation.step_result - tool_result = continuation.tool_result - elif chat_session.pending_tasks_json: - self.events.record( - request.tenant_id, - chat_session.id, - "pending_tasks_waiting", - {"pending_tasks": chat_session.pending_tasks_json or []}, - ) - - yield self._stream_status( - chat_session, "responding", "正在生成回复", user_message_id=user_message_id - ) - chunks: list[str] = [] - for chunk in self._generate_reply_stream_segment( - request.message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - model_config, - persona_prompt, - memory_context, - conversation_context, - response_task_results, - ): - chunks.append(chunk) - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - reply = "".join(chunks).strip() or FALLBACK_REPLY - if not chunks: - for chunk in self.response_generator.chunk_text(reply): - chunks.append(chunk) - yield self._stream_event( - "stream_delta", - chat_session, - self._turn_payload({"content": chunk}, user_message_id), - ) - self._pace_stream() - if not finalize_before_reply: - self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - if mark_current_turn_cancelled(): - return - repaired_reply = self._restore_reply_atomic_references(reply, step_result) - if repaired_reply != reply: - reply = repaired_reply - yield self._stream_event( - "stream_replace", - chat_session, - self._turn_payload({"content": reply}, user_message_id), - ) - yield self._stream_event( - "stream_end", chat_session, self._turn_payload({}, user_message_id) - ) - if mark_current_turn_cancelled(): - return - - except GeneratorExit: - if chat_session and user_message_id: - try: - if not mark_current_turn_cancelled(): - self.db.rollback() - except Exception: - self.db.rollback() - raise - except AgentLoopPreconditionError as exc: - yield from stream_failure_response( - "系统配置错误", - exc.message, - exc.code, - "请在管理端补齐配置后重试。", - message=exc.message, - ) - return - except LLMError as exc: - yield from stream_failure_response( - "模型调用失败", exc, "LLM_ERROR", model_failure_suggestion(exc) - ) - return - except Exception as exc: - turn_finalized = False - yield from stream_failure_response( - "Agent Loop 出错", - exc, - "AGENT_LOOP_ERROR", - "请查看执行记录或服务日志定位具体原因。", - ) - return - - turn_commit_completed = False - try: - if not chat_session: - chat_session = self._get_or_create_session(request) - if mark_current_turn_cancelled(): - return - reply = finalize_turn_once(chat_session, reply, step_result, request.message) - self.db.commit() - turn_commit_completed = True - self.db.refresh(chat_session) - if memory_model_config: - self._enqueue_memory_capture( - request, - chat_session, - step_result, - tool_result, - memory_model_config, - ) - result = ChatTurnResponse( - reply=reply, - session_id=chat_session.id, - router_decision=router_decision, - step_result=step_result, - tool_result=tool_result, - session_state=public_session(chat_session), - ) - yield self._stream_event( - "complete", - chat_session, - self._turn_payload(result.model_dump(mode="json"), user_message_id), - ) - except Exception as exc: - if not turn_commit_completed: - turn_finalized = False - yield from stream_failure_response( - "Agent Loop 出错", - exc, - "AGENT_LOOP_ERROR", - "请查看执行记录或服务日志定位具体原因。", - ) - - def _uses_harness_v2(self, request: ChatTurnRequest) -> bool: - # Harness v2 is the sole production turn lifecycle. Keep the method as - # a compatibility seam for callers and tests while removing rollout - # flags that could silently send interactive chat back to AgentLoop v1. - return True - - def _handle_turn_stream_v2( - self, request: ChatTurnRequest - ) -> Iterator[dict[str, object]]: - session_request = _with_recoverable_first_session(request) - existing_session = ( - self.db.get(ChatSession, session_request.session_id) - if session_request.session_id - else None - ) - chat_session = get_or_create_harness_session( - self, - session_request, - ) - created_session = existing_session is None - scoped_request = request.model_copy( - update={"session_id": chat_session.id} - ) - initial_turn_id = str(request.client_turn_id or "").strip() or None - if created_session: - yield self._stream_event( - "session_created", - chat_session, - { - "sessionId": chat_session.id, - "turn_id": initial_turn_id, - "client_turn_id": request.client_turn_id, - "execution_engine": "harness_v2", - }, - ) - yield self._stream_event( - "user_message_received", - chat_session, - self._turn_payload( - { - "sessionId": chat_session.id, - "client_turn_id": request.client_turn_id, - "execution_engine": "harness_v2", - }, - initial_turn_id, - ), - ) - yield self._stream_status( - chat_session, - "planning", - "正在规划本轮任务", - {"execution_engine": "harness_v2"}, - user_message_id=initial_turn_id, - ) - response = self.handle_turn(scoped_request) - chat_session = self.db.get(ChatSession, response.session_id) - if chat_session is None: - return - user_message = None - client_turn_id = str(request.client_turn_id or "").strip() - if client_turn_id: - receipt = self.db.exec( - select(HarnessTurnRecord).where( - HarnessTurnRecord.tenant_id == request.tenant_id, - HarnessTurnRecord.session_id == response.session_id, - HarnessTurnRecord.client_turn_id == client_turn_id, - ) - ).first() - if receipt is not None and receipt.user_message_id: - candidate = self.db.get(Message, receipt.user_message_id) - if ( - candidate is not None - and candidate.tenant_id == request.tenant_id - and candidate.session_id == response.session_id - and candidate.role == "user" - ): - user_message = candidate - if user_message is None and not client_turn_id: - user_message = self.db.exec( - select(Message) - .where( - Message.tenant_id == request.tenant_id, - Message.session_id == response.session_id, - Message.role == "user", - ) - .order_by(Message.created_at.desc()) - ).first() - user_message_id = user_message.id if user_message else None - if response.reply == CANCELLED_ASSISTANT_REPLY: - yield self._stream_event( - "stream_cancelled", - chat_session, - self._turn_payload( - { - "phase": "cancelled", - "text": CANCELLED_ASSISTANT_REPLY, - "client_turn_id": request.client_turn_id, - "execution_engine": "harness_v2", - }, - user_message_id or initial_turn_id, - ), - ) - return - for chunk in self.response_generator.chunk_text(response.reply): - event = self._stream_event( - "stream_delta", - chat_session, - self._turn_payload( - { - "content": chunk, - "execution_engine": "harness_v2", - }, - user_message_id, - ), - ) - self.db.commit() - yield event - end_event = self._stream_event( - "stream_end", - chat_session, - self._turn_payload( - {"execution_engine": "harness_v2"}, user_message_id - ), - ) - self.db.commit() - yield end_event - yield self._stream_event( - "complete", - chat_session, - self._turn_payload( - { - **response.model_dump(mode="json"), - "execution_engine": "harness_v2", - }, - user_message_id, - ), - ) - - def _stream_status( - self, - chat_session: ChatSession, - phase: str, - text: str, - extra: dict[str, object] | None = None, - user_message_id: str | None = None, - ) -> dict[str, object]: - payload: dict[str, object] = {"phase": phase, "text": text, **(extra or {})} - if user_message_id: - payload = self._turn_payload(payload, user_message_id) - if phase != "received": - self.events.record( - chat_session.tenant_id, chat_session.id, "stream_status", payload - ) - self.db.commit() - return self._stream_event( - "status", - chat_session, - payload, - ) - - def _stream_event( - self, - kind: str, - chat_session: ChatSession, - payload: dict[str, object], - ) -> dict[str, object]: - persisted_stream_events = { - "agent_loop_completed", - "agent_loop_continued", - "general_skill_run_finished", - "general_skill_trace", - "knowledge_result", - "reflection_decision", - "skill_state", - "step_result", - "stream_delta", - "stream_replace", - "stream_end", - "tool_result", - } - if kind in persisted_stream_events and ( - payload.get("turn_id") or payload.get("user_message_id") - ): - self.events.record(chat_session.tenant_id, chat_session.id, kind, payload) - self.db.commit() - data = { - "kind": kind, - "sessionId": chat_session.id, - "timestamp": utc_now().isoformat(), - "provider": "skill", - **payload, - } - return {"event": kind, "data": data} - - def _pace_stream(self) -> None: - sleep(STREAM_CHUNK_INTERVAL_SECONDS) - - def _prepare_turn( - self, request: ChatTurnRequest, status_callback: StatusCallback | None = None - ) -> PreparedTurn: - def status(phase: str, payload: dict[str, object] | None = None) -> None: - if status_callback: - status_callback(phase, payload or {}) - - chat_session = self._get_or_create_session(request) - self._mark_session_running(chat_session) - status("received", {"session_id": chat_session.id}) - user_message = self._append_message( - request.tenant_id, - chat_session.id, - "user", - request.message, - metadata=self._user_message_metadata(request), - ) - bind_event_turn = getattr(self.events, "bind_turn", None) - if callable(bind_event_turn): - bind_event_turn(user_message.id, request.client_turn_id) - self.events.record( - request.tenant_id, - chat_session.id, - "user_message_received", - { - "message_id": user_message.id, - "client_turn_id": request.client_turn_id, - "message": request.message, - "channel": request.channel, - "user_id": request.user_id, - }, - ) - - model_config = self._get_request_model(request, chat_session.agent_id) - skills = self._list_published_skills(request.tenant_id, chat_session.agent_id) - tools = self._tools_with_general_skills( - request.tenant_id, - self._list_enabled_tools(request.tenant_id, chat_session.agent_id), - chat_session.agent_id, - ) - if not model_config: - raise AgentLoopPreconditionError("missing_model_config", "没有默认模型配置。") - self._drop_unavailable_skill_state(request.tenant_id, chat_session, skills) - if not skills: - no_skill_context = self._conversation_context( - chat_session, model_config=model_config - ) - if self._context_compacted_now(no_skill_context): - status("preparing", {"compacted_now": True}) - capability = self._select_general_capability( - request.message, - model_config, - chat_session.agent_id, - no_skill_context, - [], - ) - router_decision = RouterDecision( - decision="answer_only", - reason="No published scene skills are available; try general skills, then answer as chat.", - ) - general_response = self._try_handle_general_skill_after_scene_router( - request, - chat_session, - model_config, - router_decision, - [], - no_skill_context, - user_message.id, - capability, - ) - if general_response: - return PreparedTurn( - chat_session=chat_session, - model_config=model_config, - active_skill=None, - router_decision=router_decision, - step_result=StepAgentResult(), - tool_result=None, - memory_context=[], - conversation_context=no_skill_context, - general_response=general_response, - user_message_id=user_message.id, - ) - step_result = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - router_decision, - capability[1], - status_callback=status, - ) - return PreparedTurn( - chat_session=chat_session, - model_config=model_config, - active_skill=None, - router_decision=router_decision, - step_result=step_result, - tool_result=None, - memory_context=[], - conversation_context=no_skill_context, - user_message_id=user_message.id, - ) - self._finish_stale_completed_skill(request.tenant_id, chat_session, skills) - memory_context = [ - memory_read(row) - for row in self.memory.context_memories( - request.tenant_id, - request.user_id, - agent_id=chat_session.agent_id, - ) - ] - if memory_context: - self.events.record( - request.tenant_id, - chat_session.id, - "memory_recalled", - {"memories": memory_context}, - ) - self.db.commit() - self.db.refresh(chat_session) - conversation_context = self._conversation_context(chat_session, model_config=model_config) - if self._context_compacted_now(conversation_context): - status("preparing", {"compacted_now": True}) - - status("routing") - router_decision = self.router.decide( - request.message, - chat_session, - skills, - model_config, - conversation_context, - memory_context, - ) - hydrated_slots = self._hydrate_router_decision_from_context( - chat_session, router_decision, skills, memory_context - ) - if hydrated_slots: - self.events.record( - request.tenant_id, - chat_session.id, - "router_slots_hydrated", - hydrated_slots, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "router_decision_created", - self._turn_payload(router_decision.model_dump(), user_message.id), - ) - capability: tuple[GeneralSkill | None, GeneralSkillSelection] | None = None - if self._scene_router_deferred_to_general(router_decision): - capability = self._select_general_capability( - request.message, - model_config, - chat_session.agent_id, - conversation_context, - memory_context, - ) - general_response = self._try_handle_general_skill_after_scene_router( - request, - chat_session, - model_config, - router_decision, - memory_context, - conversation_context, - user_message.id, - capability, - ) - if general_response: - return PreparedTurn( - chat_session=chat_session, - model_config=model_config, - active_skill=None, - router_decision=router_decision, - step_result=StepAgentResult(), - tool_result=None, - memory_context=memory_context, - conversation_context=conversation_context, - general_response=general_response, - user_message_id=user_message.id, - ) - - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - self.runtime.apply_decision(chat_session, router_decision) - state_pruned = self._drop_unavailable_skill_state(request.tenant_id, chat_session, skills) - if self._should_record_runtime_event_after_prune( - router_decision, chat_session, skills, state_pruned - ): - self._record_runtime_event( - request.tenant_id, chat_session, before_skill, before_step, router_decision - ) - self.db.commit() - self.db.refresh(chat_session) - - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - if not self._should_run_step_agent(router_decision, active_skill): - step_result = self._auto_knowledge_step_result( - request, - chat_session, - model_config, - router_decision, - capability[1] if capability else None, - status_callback=status, - ) - return PreparedTurn( - chat_session=chat_session, - model_config=model_config, - active_skill=active_skill, - router_decision=router_decision, - step_result=step_result, - tool_result=None, - memory_context=memory_context, - conversation_context=conversation_context, - user_message_id=user_message.id, - ) - status( - "stepping", - { - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - }, - ) - step_result = self._run_step_agent_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - ) - - tool_result: ToolResult | None = None - self.db.commit() - self.db.refresh(chat_session) - if step_result.knowledge_query: - step_result = self._execute_knowledge_query_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - status_callback=status, - ) - self.db.commit() - self.db.refresh(chat_session) - if step_result.tool_call: - step_result, tool_result = self._execute_tool_action_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - status_callback=status, - conversation_context=conversation_context, - memory_context=memory_context, - ) - - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._run_reflection_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - self._get_reflection_max_rounds(request.tenant_id), - conversation_context, - memory_context=memory_context, - ) - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._auto_progress_skill_graph( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - memory_context, - conversation_context, - ) - - return PreparedTurn( - chat_session=chat_session, - model_config=model_config, - active_skill=active_skill, - router_decision=router_decision, - step_result=step_result, - tool_result=tool_result, - memory_context=memory_context, - conversation_context=conversation_context, - user_message_id=user_message.id, - ) - - def _finalize_execution_after_reply( - self, - tenant_id: str, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> ExecutionFinalizeState: - return LegacyTurnFinalizer.finalize( - tenant_id, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - current_step_allows_handoff=self._current_step_allows_human_handoff, - create_handoff=self._create_human_handoff_request, - record_event=self.events.record, - should_complete=self._should_complete_skill, - complete_skill=self._complete_active_skill, - ) - - def _current_step_allows_human_handoff( - self, skill: Skill | None, active_step_id: str | None - ) -> bool: - if not skill: - return False - current_step = self._current_skill_step(skill, active_step_id) - if not current_step: - return False - return self._step_declares_human_handoff(current_step) - - def _step_declares_human_handoff(self, step: dict[str, Any]) -> bool: - node_type = str(step.get("type") or "").strip() - return node_type == "handoff" or "handoff_human" in self._step_actions(step) - - def _create_human_handoff_request( - self, - tenant_id: str, - chat_session: ChatSession, - active_skill: Skill | None, - step_result: StepAgentResult, - ) -> HumanHandoffRequest: - return HumanHandoffService(self.db, self.events).create( - tenant_id, - chat_session, - step_result, - current_step_resolver=lambda: ( - self._current_skill_step(active_skill, chat_session.active_step_id) - if active_skill - else None - ), - assignee_resolver=self._human_handoff_assignee_user_id, - context_summary=self._human_handoff_context_summary, - pending_question=self._human_handoff_pending_question, - ) - - def _human_handoff_assignee_user_id( - self, tenant_id: str, agent_id: str | None, fallback_user_id: str | None - ) -> str | None: - return HumanHandoffService(self.db, getattr(self, "events", None)).assignee_user_id( - tenant_id, - agent_id, - fallback_user_id, - tenant_admin_resolver=self._human_handoff_tenant_admin_user_id, - ) - - def _human_handoff_tenant_admin_user_id(self, tenant_id: str) -> str | None: - return HumanHandoffService( - self.db, getattr(self, "events", None) - ).tenant_admin_user_id(tenant_id) - - def _human_handoff_context_summary(self, chat_session: ChatSession) -> str: - return HumanHandoffService( - self.db, getattr(self, "events", None) - ).context_summary(chat_session) - - def _human_handoff_pending_question( - self, current_step: dict[str, Any] | None, step_result: StepAgentResult - ) -> str: - return HumanHandoffService.pending_question(current_step, step_result) - - def _generate_reply_segment( - self, - message: str, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - model_config: ModelConfig, - persona_prompt: str | None, - memory_context: list[dict[str, object]], - conversation_context: dict[str, object], - task_results: list[dict[str, object]] | None = None, - ) -> str: - return self.response_generator.generate( - message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - model_config, - persona_prompt, - memory_context, - conversation_context, - task_results, - ) - - def _generate_reply_stream_segment( - self, - message: str, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - model_config: ModelConfig, - persona_prompt: str | None, - memory_context: list[dict[str, object]], - conversation_context: dict[str, object], - task_results: list[dict[str, object]] | None = None, - ) -> Iterator[str]: - yield from self.response_generator.generate_stream( - message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - model_config, - persona_prompt, - memory_context, - conversation_context, - task_results, - ) - - def _task_response_context( - self, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> dict[str, object]: - return { - "task": router_decision.user_intent - or (active_skill.name if active_skill else "当前任务"), - "current_step_id": chat_session.active_step_id, - "skill_content": dict(active_skill.content_json or {}) if active_skill else None, - "slots": dict(chat_session.slots_json or {}), - "step_result": step_result.model_dump(mode="json"), - "tool_result": tool_result.model_dump(mode="json") if tool_result else None, - } - - def _task_response_draft(self, step_result: StepAgentResult) -> str: - return str(step_result.reply or "").strip() - - def _try_continue_pending_after_completion( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - skills: list[Skill], - tools: list[Any], - persona_prompt: str | None, - memory_context: list[dict[str, object]], - conversation_context: dict[str, object], - completed_reply: str, - completed_skill_ids_this_turn: set[str] | None = None, - turn_task_frames: list[PendingTask] | None = None, - ) -> QueuedTaskContinuation | None: - remaining_turn_frames = list(turn_task_frames or []) - uses_turn_frames = turn_task_frames is not None - if uses_turn_frames and not remaining_turn_frames: - return None - if not uses_turn_frames and not chat_session.pending_tasks_json: - return None - max_actions = max(1, self._get_agent_loop_max_actions(request.tenant_id)) - executed_actions = 0 - replies: list[str] = [] - task_results: list[dict[str, object]] = [] - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - active_skill: Skill | None = None - router_decision = RouterDecision(decision="answer_only", reason="No pending task selected") - step_result = StepAgentResult() - tool_result: ToolResult | None = None - - for queue_round in range(max_actions): - if uses_turn_frames: - if not remaining_turn_frames: - break - turn_frame = remaining_turn_frames.pop(0) - task_id = turn_frame.task_id or f"turn_task_{queue_round + 1}" - else: - if not chat_session.pending_tasks_json: - break - task_id = self._next_pending_task_id(chat_session) - if not task_id: - break - self.events.record( - request.tenant_id, - chat_session.id, - "router_execution_order_advanced", - {"task_id": task_id, "queue_round": queue_round + 1}, - ) - - for task_id in [task_id]: - if executed_actions >= max_actions: - break - router_decision = ( - self._router_decision_from_turn_task_frame(turn_frame) - if uses_turn_frames - else self._router_decision_from_task_frame( - chat_session, - task_id, - "按 Router 已确定的任务顺序继续执行。", - ) - ) - if not router_decision: - continue - - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - self.runtime.apply_decision(chat_session, router_decision) - state_pruned = self._drop_unavailable_skill_state( - request.tenant_id, chat_session, skills - ) - if self._should_record_runtime_event_after_prune( - router_decision, chat_session, skills, state_pruned - ): - self._record_runtime_event( - request.tenant_id, chat_session, before_skill, before_step, router_decision - ) - self.db.commit() - self.db.refresh(chat_session) - - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - if not self._should_run_step_agent(router_decision, active_skill): - continue - step_result = self._run_step_agent_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - ) - self.db.commit() - self.db.refresh(chat_session) - tool_result = None - if step_result.knowledge_query: - step_result = self._execute_knowledge_query_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - ) - self.db.commit() - self.db.refresh(chat_session) - if step_result.tool_call: - step_result, tool_result = self._execute_tool_action_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - conversation_context=conversation_context, - memory_context=memory_context, - ) - - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._run_reflection_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - self._get_reflection_max_rounds(request.tenant_id), - conversation_context, - completed_skill_ids_this_turn=completed_skill_ids_this_turn, - memory_context=memory_context, - ) - ( - active_skill, - router_decision, - step_result, - tool_result, - ) = self._auto_progress_skill_graph( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - memory_context, - conversation_context, - completed_skill_ids_this_turn=completed_skill_ids_this_turn, - ) - task_results.append( - self._task_response_context( - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - ) - draft = self._task_response_draft(step_result) - if draft: - replies, _ = self._merge_queued_reply_segment(replies, draft) - executed_actions += 1 - finalize_state = self._finalize_execution_after_reply( - request.tenant_id, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - ) - if finalize_state == "completed" and active_skill: - completed_skill_ids_this_turn.add(active_skill.skill_id) - if finalize_state == "handoff": - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - if finalize_state == "continued": - if uses_turn_frames and remaining_turn_frames: - if chat_session.active_skill_id: - self.runtime.suspend_current_skill(chat_session, enqueue=True) - self.db.commit() - self.db.refresh(chat_session) - continue - if self._should_attempt_queued_task_followup( - request, - chat_session, - skills, - "\n\n".join([completed_reply, *replies]).strip(), - queue_round + 1, - ): - if active_skill: - completed_skill_ids_this_turn.add(active_skill.skill_id) - continue - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "pending_tasks_waiting", - { - "pending_tasks": chat_session.pending_tasks_json or [], - "round": queue_round + 1, - }, - ) - if executed_actions >= max_actions: - break - - return self._queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - - def _queued_continuation( - self, - replies: list[str], - task_results: list[dict[str, object]], - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> QueuedTaskContinuation | None: - return TaskFramePolicy.queued_continuation( - replies, - task_results, - active_skill, - router_decision, - step_result, - tool_result, - ) - - def _should_attempt_queued_task_followup( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - completed_reply: str, - schedule_round: int, - ) -> bool: - if request.interaction_mode != "scheduled_task": - return False - if chat_session.awaiting_input_json: - return False - if not chat_session.pending_tasks_json: - return False - - self._finish_stale_completed_skill(request.tenant_id, chat_session, skills) - self._drop_unavailable_skill_state(request.tenant_id, chat_session, skills) - self.db.commit() - self.db.refresh(chat_session) - - if chat_session.awaiting_input_json or chat_session.active_skill_id: - return False - if not chat_session.pending_tasks_json: - return False - - self.events.record( - request.tenant_id, - chat_session.id, - "scheduled_task_followup_requested", - { - "round": schedule_round, - "pending_tasks": chat_session.pending_tasks_json or [], - "completed_reply": completed_reply[:500], - "reason": "scheduled_task_mode_attempts_to_finish_pending_work", - }, - ) - return True - def _merge_queued_reply_segment( - self, replies: list[str], segment: str - ) -> tuple[list[str], bool]: - return TaskFramePolicy.merge_reply_segment(replies, segment) - - def _router_decision_from_task_frame( - self, - chat_session: ChatSession, - task_id: str, - order_reason: str | None = None, - ) -> RouterDecision | None: - frame = self._find_task_frame(chat_session, task_id) - if not frame: - return None - return TaskFramePolicy.decision_from_frame(frame, task_id, order_reason) - - def _find_task_frame(self, chat_session: ChatSession, task_id: str) -> dict[str, Any] | None: - return TaskFramePolicy.find(chat_session, task_id) - - def _turn_followup_task_frames( - self, router_decision: RouterDecision - ) -> list[PendingTask]: - return TaskFramePolicy.turn_followup_frames(router_decision) - - def _router_decision_from_turn_task_frame( - self, frame: PendingTask - ) -> RouterDecision: - return TaskFramePolicy.decision_from_turn_frame(frame) - - def _next_pending_task_id(self, chat_session: ChatSession) -> str | None: - return TaskFramePolicy.next_pending_task_id(chat_session) - - def _run_reflection_rounds( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - model_config: ModelConfig, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - max_rounds: int, - conversation_context: dict[str, object] | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - completed_skill_ids_this_turn: set[str] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - events = getattr(self, "events", None) - return LegacyReflectionCoordinator.run_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - max_rounds, - conversation_context, - stream_events, - completed_skill_ids_this_turn, - memory_context, - round_limit=REFLECTION_MAX_ROUNDS_LIMIT, - context_loader=self._conversation_context, - should_try=self._should_try_reflection, - reflect_and_retry=self._reflect_and_retry, - record_event=events.record if events is not None else None, - ) - - def _auto_progress_skill_graph( + def _stream_status( self, - request: ChatTurnRequest, chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - model_config: ModelConfig, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - completed_skill_ids_this_turn: set[str] | None = None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - if conversation_context is None: - conversation_context = self._conversation_context(chat_session) - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - max_actions = max(1, self._get_agent_loop_max_actions(request.tenant_id)) - for iteration in range(max_actions): - active_skill = self._get_active_skill( - request.tenant_id, chat_session.active_skill_id, chat_session.agent_id - ) - if not active_skill or not step_result.is_step_completed: - break - if step_result.tool_call or step_result.handoff: - break - if not self._graph_flow_has_unfinished_work(active_skill, chat_session, step_result): - break - if not self._current_step_expected_info_satisfied(active_skill, chat_session): - break - - payload = { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - "pending_step_ids": self._graph_pending_steps(chat_session), - "iteration": iteration + 1, - "max_iterations": max_actions, - } - self.events.record( - request.tenant_id, chat_session.id, "graph_auto_progress_started", payload - ) - if stream_events is not None: - stream_events.append(("status", payload)) - - before_state = ( - chat_session.active_step_id, - tuple(self._graph_pending_steps(chat_session)), - ) - router_decision = RouterDecision( - decision="continue_active", - target_skill_id=active_skill.skill_id, - target_step_id=chat_session.active_step_id, - confidence=max(router_decision.confidence, 0.7), - user_intent=router_decision.user_intent or "继续执行 SOP 图", - reason="SOP 图还有可自动执行的后续节点。", - source_message=router_decision.source_message or request.message, - slot_hints={}, - ) - repair_events: list[tuple[str, dict[str, object]]] | None = ( - [] if stream_events is not None else None - ) - step_result = self._run_step_agent_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - repair_events, - ) - self.db.commit() - self.db.refresh(chat_session) - if repair_events: - stream_events.extend(repair_events) - - if step_result.knowledge_query: - knowledge_events: list[tuple[str, dict[str, object]]] | None = ( - [] if stream_events is not None else None - ) - step_result = self._execute_knowledge_query_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - knowledge_events, - ) - self.db.commit() - self.db.refresh(chat_session) - if knowledge_events: - stream_events.extend(knowledge_events) - - if step_result.tool_call: - tool_events: list[tuple[str, dict[str, object]]] | None = ( - [] if stream_events is not None else None - ) - step_result, tool_result = self._execute_tool_action_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - tool_events, - conversation_context=conversation_context, - memory_context=memory_context, + phase: str, + text: str, + extra: dict[str, object] | None = None, + user_message_id: str | None = None, + ) -> dict[str, object]: + payload: dict[str, object] = {"phase": phase, "text": text, **(extra or {})} + if user_message_id: + payload = self._turn_payload(payload, user_message_id) + if phase != "received": + self.events.record( + chat_session.tenant_id, chat_session.id, "stream_status", payload ) self.db.commit() - self.db.refresh(chat_session) - if tool_events: - stream_events.extend(tool_events) - - reflection_events: list[tuple[str, dict[str, object]]] | None = ( - [] if stream_events is not None else None - ) - active_skill, router_decision, step_result, tool_result = self._run_reflection_rounds( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - self._get_reflection_max_rounds(request.tenant_id), - conversation_context, - reflection_events, - completed_skill_ids_this_turn, - memory_context, - ) - if reflection_events: - stream_events.extend(reflection_events) - - after_state = ( - chat_session.active_step_id, - tuple(self._graph_pending_steps(chat_session)), - ) - if ( - after_state == before_state - and not step_result.tool_call - and not step_result.knowledge_query - ): - break - return active_skill, router_decision, step_result, tool_result - - def _reflect_and_retry( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - model_config: ModelConfig, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - conversation_context: dict[str, object] | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - completed_skill_ids_this_turn: set[str] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None, bool]: - return LegacyReflectionCoordinator.reflect_and_retry( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - conversation_context, - stream_events, - completed_skill_ids_this_turn, - memory_context, - context_loader=self._conversation_context, - should_try=self._should_try_reflection, - review=lambda *args, **kwargs: self.reflection_agent.review(*args, **kwargs), - llm_error_type=LLMError, - record_event=lambda *args, **kwargs: self.events.record(*args, **kwargs), - tool_call_from_reflection=self._tool_call_from_reflection, - tool_retry_targets_current_skill=self._reflection_tool_retry_targets_current_skill, - retry_with_tool_call=self._retry_with_reflection_tool_call, - router_decision_from_reflection=self._router_decision_from_reflection, - retry_with_router_decision=self._retry_with_router_decision, - ) - - def _retry_with_reflection_tool_call( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - retry_tool_call: ToolCall, - retry_reason: str | None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - tools: list[Tool] | None = None, - model_config: ModelConfig | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - return LegacyReflectionCoordinator.retry_with_tool_call( - request, - chat_session, - active_skill, - router_decision, - retry_tool_call, - retry_reason, - stream_events, - tools, - model_config, - conversation_context, - memory_context, - record_event=lambda *args, **kwargs: self.events.record(*args, **kwargs), - execute_tool_cycle=self._execute_tool_action_cycle, - ) - - def _reflection_tool_retry_targets_current_skill( - self, reflection: ReflectionDecision, chat_session: ChatSession - ) -> bool: - return LegacyReflectionPolicy.retry_targets_current_skill( - reflection, chat_session - ) - - def _retry_with_router_decision( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - router_decision: RouterDecision, - model_config: ModelConfig, - conversation_context: dict[str, object], - stream_events: list[tuple[str, dict[str, object]]] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - return LegacyReflectionCoordinator.retry_with_router_decision( - request, - chat_session, - skills, - tools, - router_decision, - model_config, - conversation_context, - stream_events, - memory_context, - record_event=lambda *args, **kwargs: self.events.record(*args, **kwargs), - apply_runtime_decision=lambda session, decision: self.runtime.apply_decision( - session, decision - ), - drop_unavailable_state=self._drop_unavailable_skill_state, - should_record_runtime_event=self._should_record_runtime_event_after_prune, - record_runtime_event=self._record_runtime_event, - commit=lambda: self.db.commit(), - refresh=lambda session: self.db.refresh(session), - get_active_skill=self._get_active_skill, - skill_state_payload=self._skill_state_payload, - runtime_stream_context=self._runtime_stream_context, - run_step_with_context_repair=self._run_step_agent_with_context_repair, - execute_knowledge_cycle=self._execute_knowledge_query_cycle, - execute_tool_cycle=self._execute_tool_action_cycle, - ) - - def _tool_loop_decision_payload( - self, - iteration: int, - mode: str, - tool_call: ToolCall | None = None, - ) -> dict[str, object]: - payload: dict[str, object] = {"mode": mode, "iteration": iteration} - if tool_call: - payload["tool_call"] = tool_call.model_dump(mode="json") - payload["target_tool_name"] = tool_call.name - return payload - - def _execute_tool_action_cycle( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig | None, - step_result: StepAgentResult, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - status_callback: StatusCallback | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[StepAgentResult, ToolResult | None]: - callbacks = LegacyToolActionCallbacks( - max_actions=self._get_agent_loop_max_actions, - decision_payload=self._tool_loop_decision_payload, - new_id=new_id, - is_general_skill_tool=lambda name: name.startswith(GENERAL_SKILL_TOOL_PREFIX), - call_signature=self._tool_call_signature, - emit_tool_status=self._emit_tool_status, - execute_tool_call=self._execute_tool_call, - record_result=self._record_tool_result_in_slots, - activity_payload=self._tool_activity_payload, - emit_thinking_status=self._emit_thinking_status, - run_step=self._run_step_agent_once, - continuation_context=self._tool_continuation_context, - apply_result=self._apply_step_result, - advance_after_tool=self._advance_after_successful_tool, - ) - return LegacyToolAction(self.db, self.events).execute_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - stream_events, - status_callback, - conversation_context, - memory_context, - callbacks, - ) - - def _execute_knowledge_query_cycle( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - step_result: StepAgentResult, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - status_callback: StatusCallback | None = None, - ) -> StepAgentResult: - return LegacyKnowledgeAction( - getattr(self, "db", None), getattr(self, "events", None) - ).execute_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - stream_events, - status_callback, - key_resolver=self._knowledge_step_key, - query_claimer=self._claim_knowledge_query, - visible_knowledge_base_ids=self._agent_visible_knowledge_base_ids, - requires_resource_filter=self._agent_requires_resource_filter, - continue_after_query=self._continue_after_knowledge_query, - scope_ids=_knowledge_scope_ids, - knowledge_service_factory=KnowledgeService, - ) - - def _continue_after_knowledge_query( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - knowledge_items: dict[str, Any], - memory_context: list[dict[str, object]] | None, - conversation_context: dict[str, object] | None, - ) -> StepAgentResult: - return LegacyKnowledgeAction( - getattr(self, "db", None), getattr(self, "events", None) - ).continue_after_query( - request, - chat_session, - active_skill, - tools, - model_config, - knowledge_items, - memory_context, - conversation_context, - step_runner=self._run_step_agent_once, - result_applier=self._apply_step_result, - ) - - def _auto_knowledge_step_result( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - router_decision: RouterDecision, - selection: GeneralSkillSelection | None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - status_callback: StatusCallback | None = None, - ) -> StepAgentResult: - return LegacyKnowledgeAction( - getattr(self, "db", None), getattr(self, "events", None) - ).auto_step_result( - request, + return self._stream_event( + "status", chat_session, - model_config, - router_decision, - selection, - stream_events, - status_callback, - items_loader=self._knowledge_items_for_message, - ) - - def _knowledge_items_for_message( - self, - tenant_id: str, - agent_id: str, - message: str, - query: KnowledgeQuery | None = None, - model_config: ModelConfig | None = None, - ) -> dict[str, Any] | None: - return LegacyKnowledgeAction( - getattr(self, "db", None), getattr(self, "events", None) - ).items_for_message( - tenant_id, - agent_id, - message, - query, - model_config, - visible_knowledge_base_ids=self._agent_visible_knowledge_base_ids, - requires_resource_filter=self._agent_requires_resource_filter, - scope_ids=_knowledge_scope_ids, - knowledge_service_factory=KnowledgeService, + payload, ) - def _tool_continuation_context( + def _stream_event( self, - tenant_id: str, - tool_call: ToolCall, - tool_result: ToolResult, + kind: str, chat_session: ChatSession, - completed_actions: int, + payload: dict[str, object], ) -> dict[str, object]: - slots = chat_session.slots_json or {} - max_actions = self._get_agent_loop_max_actions(tenant_id) - return { - "reason": "tool_continuation", - "previous_tool_call": tool_call.model_dump(mode="json"), - "previous_tool_result": tool_result.model_dump(mode="json"), - "accumulated_tool_results": slots.get(TOOL_RESULTS_SLOT, []), - "tool_call_history": slots.get(TOOL_CALL_HISTORY_SLOT, []), - "completed_tool_actions_this_turn": completed_actions, - "max_tool_actions_per_turn": max_actions, - "instruction": ( - "基于工具结果、slots、当前技能步骤和用户目标判断是否已经完成。" - "如果还需要工具调用,由模型输出下一次 tool_call;" - "如果已经足够回复,输出无 tool_call 的结果并推进到可回复步骤。" - "不要重复调用 tool_call_history 中相同 name + arguments 的工具。" - ), - } - - def _emit_tool_status( - self, - tool_call: ToolCall, - tool_call_id: str, - stream_events: list[tuple[str, dict[str, object]]] | None, - status_callback: StatusCallback | None, - ) -> None: - payload = { - "phase": "tool", - "text": f"正在调用工具 {tool_call.name}", - "tool_name": tool_call.name, - "tool_call_id": tool_call_id, - "tool_call": tool_call.model_dump(mode="json"), - } - if stream_events is not None: - stream_events.append(("status", payload)) - if status_callback is not None: - status_callback("tool", {"tool_name": tool_call.name}) - - def _emit_thinking_status( - self, - chat_session: ChatSession, - iteration: int, - stream_events: list[tuple[str, dict[str, object]]] | None, - status_callback: StatusCallback | None, - ) -> None: - payload = { - "phase": "stepping", - "text": "正在思考", - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - "repair_reason": "tool_continuation", - "iteration": iteration, - } - if stream_events is not None: - stream_events.append(("status", payload)) - if status_callback is not None: - status_callback( - "stepping", - { - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - "repair_reason": "tool_continuation", - }, - ) - - def _run_step_agent_with_context_repair( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - router_decision: RouterDecision, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - ) -> StepAgentResult: - if conversation_context is None: - conversation_context = self._conversation_context(chat_session) - selected_general_result = self._preselect_general_skill_for_scene( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context, - conversation_context, - stream_events, - ) - if selected_general_result is not None: - return selected_general_result - step_result = self._run_step_agent_once( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context=memory_context, - conversation_context=conversation_context, - allow_general_skill_selection=False, - ) - step_result = self._normalize_required_knowledge_step( - request, chat_session, active_skill, step_result - ) - self._apply_step_result(request.tenant_id, chat_session, step_result, active_skill) - step_result = self._retry_slot_validation_if_needed( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - step_result, - memory_context, - conversation_context, - ) - - return step_result + persisted_stream_events = { + "agent_loop_completed", + "agent_loop_continued", + "general_skill_run_finished", + "general_skill_trace", + "knowledge_result", + "reflection_decision", + "skill_state", + "step_result", + "stream_delta", + "stream_replace", + "stream_end", + "tool_result", + } + if kind in persisted_stream_events and ( + payload.get("turn_id") or payload.get("user_message_id") + ): + self.events.record(chat_session.tenant_id, chat_session.id, kind, payload) + self.db.commit() + data = { + "kind": kind, + "sessionId": chat_session.id, + "timestamp": utc_now().isoformat(), + "provider": "skill", + **payload, + } + return {"event": kind, "data": data} - def _normalize_required_knowledge_step( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - step_result: StepAgentResult, - ) -> StepAgentResult: - return LegacyKnowledgeAction( - getattr(self, "db", None), getattr(self, "events", None) - ).normalize_required_step( - request, - chat_session, - active_skill, - step_result, - current_step=self._current_skill_step, - slot_satisfied=self._skill_slot_satisfied, - ) + def _pace_stream(self) -> None: + sleep(STREAM_CHUNK_INTERVAL_SECONDS) - def _claim_knowledge_query( - self, chat_session: ChatSession, active_skill: Skill | None + def _current_step_allows_human_handoff( + self, skill: Skill | None, active_step_id: str | None ) -> bool: - return LegacyKnowledgeAction.claim_query(chat_session, active_skill) + if not skill: + return False + current_step = self._current_skill_step(skill, active_step_id) + if not current_step: + return False + return self._step_declares_human_handoff(current_step) - def _knowledge_step_key( - self, chat_session: ChatSession, active_skill: Skill | None - ) -> tuple[str, str]: - return LegacyKnowledgeAction.step_key(chat_session, active_skill) + def _step_declares_human_handoff(self, step: dict[str, Any]) -> bool: + node_type = str(step.get("type") or "").strip() + return node_type == "handoff" or "handoff_human" in self._step_actions(step) - def _preselect_general_skill_for_scene( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - router_decision: RouterDecision, - memory_context: list[dict[str, object]] | None, - conversation_context: dict[str, object] | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - ) -> StepAgentResult | None: - if active_skill is None: - return None - # General-skill selection is independent from the scene Router decision. - # The Router may provide a hint, but must not gate this second capability check. - selection_query = str(request.message or "").strip() - if not selection_query: - return None - enabled_general_tools = { - str(getattr(tool, "name", "") or "") - for tool in tools - if getattr(tool, "enabled", False) - and str(getattr(tool, "name", "") or "").startswith( - GENERAL_SKILL_TOOL_PREFIX - ) - } - if not enabled_general_tools: - return None - skill, selection = self._select_general_capability( - selection_query, - model_config, - chat_session.agent_id, - conversation_context, - memory_context, + def _human_handoff_assignee_user_id( + self, tenant_id: str, agent_id: str | None, fallback_user_id: str | None + ) -> str | None: + return HumanHandoffService(self.db, getattr(self, "events", None)).assignee_user_id( + tenant_id, + agent_id, + fallback_user_id, + tenant_admin_resolver=self._human_handoff_tenant_admin_user_id, ) - if skill is None: - return None - tool_name = f"{GENERAL_SKILL_TOOL_PREFIX}{skill.slug}" - if tool_name not in enabled_general_tools: - return None - # Prefer the Router's focused subtask as the execution query when it exists; - # selection itself remains independent and always uses the original message. - query = str(router_decision.general_intent or "").strip() or selection_query - self._validated_general_skill_calls.add( - self._general_skill_call_key(chat_session.id, tool_name, query) - ) - selection_payload = { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": selection.operation, - "confidence": selection.confidence, - "reason": selection.reason, - "scene_router_decision": router_decision.model_dump(mode="json"), - "execution_mode": "scene_and_general", - } - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_intent_checked", - selection_payload, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_selected", - selection_payload, + def _human_handoff_tenant_admin_user_id(self, tenant_id: str) -> str | None: + return HumanHandoffService(self.db, getattr(self, "events", None)).tenant_admin_user_id( + tenant_id ) - if stream_events is not None: - stream_events.extend( - [ - ("general_skill_intent_checked", selection_payload), - ("general_skill_selected", selection_payload), - ] - ) - result = StepAgentResult( - action="call_tool", - tool_call=ToolCall( - name=tool_name, - arguments={"query": query, "operation": selection.operation}, - ), + def _human_handoff_context_summary(self, chat_session: ChatSession) -> str: + return HumanHandoffService(self.db, getattr(self, "events", None)).context_summary( + chat_session ) - self.events.record( - request.tenant_id, - chat_session.id, - "step_agent_result_created", - { - **result.model_dump(mode="json"), - "execution_source": "general_skill_preselection", - }, + + def _human_handoff_pending_question( + self, current_step: dict[str, Any] | None, step_result: StepAgentResult + ) -> str: + return HumanHandoffService.pending_question(current_step, step_result) + + def _step_actions(self, step: dict[str, Any]) -> list[str]: + return GraphRules.step_actions(step) + + def _finish_stale_completed_skill( + self, tenant_id: str, chat_session: ChatSession, skills: list[Skill] + ) -> None: + if chat_session.skill_stack_json or chat_session.resume_after_answer_json: + chat_session.skill_stack_json = [] + chat_session.resume_after_answer_json = None + chat_session.updated_at = utc_now() + active_skill = next( + (skill for skill in skills if skill.skill_id == chat_session.active_skill_id), None ) - return result + if active_skill and self._is_terminal_skill_state(active_skill, chat_session): + self._complete_active_skill( + tenant_id, chat_session, active_skill, "stale_terminal_state" + ) - def _retry_slot_validation_if_needed( + def _should_complete_skill( self, - request: ChatTurnRequest, + skill: Skill | None, chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - router_decision: RouterDecision, step_result: StepAgentResult, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - ) -> StepAgentResult: - missing_fields = self._missing_expected_fields(active_skill, chat_session) + tool_result: ToolResult | None, + ) -> bool: + if not skill or not step_result.is_step_completed: + return False + if tool_result and not tool_result.success: + return False if ( - not missing_fields - or step_result.tool_call - or step_result.handoff - or not self._router_allows_schema_tool_repair(router_decision, chat_session) - or not self._slot_validation_retry_is_worthwhile(router_decision, step_result) - ): - return step_result - - validation_result = self._run_step_agent_once( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - repair_reason="slot_validation", - repair_context={ - "reason": "slot_validation", - "missing_expected_user_info": missing_fields, - "previous_step_result": step_result.model_dump(mode="json"), - }, - memory_context=memory_context, - conversation_context=conversation_context, - allow_general_skill_selection=False, - ) - if not self._step_result_has_progress( - validation_result - ) and not self._step_result_has_reply_repair( - step_result, - validation_result, + tool_result + and tool_result.success + and self._current_step_can_finish_after_tool(skill, chat_session) ): - return step_result - if not validation_result.reply and step_result.reply: - validation_result.reply = step_result.reply - validation_result = self._normalize_required_knowledge_step( - request, chat_session, active_skill, validation_result - ) - self._apply_step_result(request.tenant_id, chat_session, validation_result, active_skill) - self.events.record( - request.tenant_id, - chat_session.id, - "step_agent_result_repaired", - { - "mode": "slot_validation", - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - "missing_expected_user_info": missing_fields, - "slot_updates": validation_result.slot_updates, - "tool_call": validation_result.tool_call.model_dump() - if validation_result.tool_call - else None, - }, - ) - return validation_result - - def _slot_validation_retry_is_worthwhile( - self, router_decision: RouterDecision, step_result: StepAgentResult - ) -> bool: - if step_result.slot_updates: return True - return router_decision.decision in { - "start_new_task", - "continue_active", - } + if self._graph_pending_steps(chat_session): + return False + if self._is_answer_ready_skill_state(skill, chat_session): + return True + if self._is_terminal_skill_state(skill, chat_session): + return True + if not step_result.next_step_id and not step_result.tool_call: + return True + if self._graph_flow_has_unfinished_work(skill, chat_session, step_result): + return False + return self._is_terminal_skill_state(skill, chat_session) - def _step_result_has_progress(self, step_result: StepAgentResult) -> bool: - return bool( - step_result.slot_updates - or step_result.tool_call - or step_result.knowledge_query - or step_result.handoff + def _is_terminal_skill_state(self, skill: Skill, chat_session: ChatSession) -> bool: + return self._is_terminal_skill_position( + skill, chat_session.active_step_id, chat_session.slots_json or {} ) - def _step_result_has_reply_repair( - self, previous_result: StepAgentResult, validation_result: StepAgentResult - ) -> bool: - previous_reply = (previous_result.reply or "").strip() - repaired_reply = (validation_result.reply or "").strip() - return bool(repaired_reply and repaired_reply != previous_reply) - - def _missing_expected_fields(self, skill: Skill | None, chat_session: ChatSession) -> list[str]: - if not skill: - return [] + def _is_answer_ready_skill_state(self, skill: Skill, chat_session: ChatSession) -> bool: step = self._current_skill_step(skill, chat_session.active_step_id) if not step: - return [] - slots = chat_session.slots_json or {} - return [ - str(field) - for field in step.get("expected_user_info", []) - if not self._skill_slot_satisfied(slots, str(field)) - ] + return False + actions = self._step_actions(step) + if not self._actions_allow_final_reply(actions): + return False + required = [str(field) for field in (skill.content_json or {}).get("required_info", [])] + return all( + self._skill_slot_satisfied(chat_session.slots_json or {}, field) for field in required + ) - def _router_allows_schema_tool_repair( - self, router_decision: RouterDecision, chat_session: ChatSession + def _graph_flow_has_unfinished_work( + self, + skill: Skill | None, + chat_session: ChatSession, + step_result: StepAgentResult | None = None, ) -> bool: - if router_decision.decision not in { - "start_new_task", - "continue_active", - }: + if not skill or chat_session.active_skill_id != skill.skill_id: return False + if self._graph_pending_steps(chat_session): + return True if ( - router_decision.target_skill_id - and chat_session.active_skill_id - and router_decision.target_skill_id != chat_session.active_skill_id + step_result + and step_result.next_step_id + and str(step_result.next_step_id) == str(chat_session.active_step_id) ): + return True + if not chat_session.active_step_id: return False - return True + return bool(self._graph_outgoing_edges(skill).get(chat_session.active_step_id)) - def _run_step_agent_once( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - router_decision: RouterDecision | None = None, - repair_reason: str | None = None, - repair_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - conversation_context: dict[str, object] | None = None, - current_knowledge: list[dict[str, object]] | None = None, - allow_general_skill_selection: bool = True, - ) -> StepAgentResult: - if conversation_context is None: - conversation_context = self._conversation_context(chat_session) - recent_messages = [ - message - for message in conversation_context.get("messages", []) - if isinstance(message, dict) and message.get("role") in {"user", "assistant"} - ] - step_result = self.step_agent.run( - message=request.message, - session=chat_session, - skill=active_skill, - tools=self._step_agent_tools( - active_skill, - tools, - request.message, - model_config, - chat_session.agent_id, - conversation_context, - memory_context, - active_step_id=chat_session.active_step_id, - slots=chat_session.slots_json, - allow_general_skill_selection=allow_general_skill_selection, - ), - model_config=model_config, - router_decision=router_decision, - repair_context=repair_context, - recent_messages=recent_messages, - memory_context=memory_context, - conversation_context=conversation_context, - current_knowledge=current_knowledge, + def _is_terminal_skill_position( + self, skill: Skill, active_step_id: str | None, slots: dict[str, Any] + ) -> bool: + if not active_step_id: + return False + content = skill.content_json or {} + terminal_node_ids = {str(node_id) for node_id in content.get("terminal_node_ids", [])} + if active_step_id not in terminal_node_ids: + return False + return GraphRules.terminal_position_from_step( + content, + active_step_id, + slots, + self._current_skill_step(skill, active_step_id), + self._skill_slot_satisfied, + self._step_actions, + ) + + def _current_step_can_finish_after_tool(self, skill: Skill, chat_session: ChatSession) -> bool: + step = self._current_skill_step(skill, chat_session.active_step_id) + if not step: + return False + actions = self._step_actions(step) + if not self._actions_allow_final_reply(actions): + return False + expected = [str(field) for field in step.get("expected_user_info", [])] + return all( + self._skill_slot_satisfied(chat_session.slots_json or {}, field) for field in expected ) - payload = step_result.model_dump() - if repair_reason: - payload["repair_reason"] = repair_reason + + def _actions_allow_final_reply(self, actions: list[str]) -> bool: + return GraphRules.actions_allow_final_reply(actions) + + def _complete_active_skill( + self, tenant_id: str, chat_session: ChatSession, skill: Skill, reason: str + ) -> None: + before_skill = chat_session.active_skill_id + before_step = chat_session.active_step_id + self.runtime.complete_current_skill(chat_session) self.events.record( - request.tenant_id, + tenant_id, chat_session.id, - "step_agent_result_created", - payload, + "skill_completed", + { + "skill_id": before_skill or skill.skill_id, + "step_id": before_step, + "reason": reason, + "resumed_skill_id": chat_session.active_skill_id, + "resumed_step_id": chat_session.active_step_id, + }, ) - return step_result - def _step_agent_tools( + def _finalize_execution_after_reply( self, + tenant_id: str, + chat_session: ChatSession, active_skill: Skill | None, - tools: list[Tool], - user_message: str | None = None, - model_config: ModelConfig | None = None, - agent_id: str | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - *, - active_step_id: str | None = None, - slots: dict[str, object] | None = None, - allow_general_skill_selection: bool = True, - ) -> list[Tool]: - if active_skill is None: - return [] - current_step = self._current_skill_step(active_skill, active_step_id) - if not current_step: - return [] - actions = { - str(action).strip() - for action in current_step.get("allowed_actions") or [] - if str(action).strip() - } - explicit_tool_names = { - action.split(":", 1)[1] - for action in actions - if action.startswith("call_tool:") and ":" in action - } - allow_any_tool = "call_tool" in actions - active_skill_id = active_skill.skill_id - scoped_tools: list[Tool] = [] - general_skill_tools: list[Tool] = [] - for tool in tools: - if not getattr(tool, "enabled", False): - continue - tool_name = str(getattr(tool, "name", "") or "") - if tool_name.startswith(GENERAL_SKILL_TOOL_PREFIX): - if allow_general_skill_selection: - general_skill_tools.append(tool) - continue - if not allow_any_tool and tool_name not in explicit_tool_names: - continue - allowed_skills = [ - str(skill_id) - for skill_id in (getattr(tool, "allowed_skills_json", None) or []) - if str(skill_id).strip() - ] - if allowed_skills and active_skill_id not in allowed_skills: - continue - scoped_tools.append(tool) - selected_general_tool = self._selected_general_skill_tool_name( - user_message, - model_config, - agent_id, - general_skill_tools, - conversation_context, - memory_context, + router_decision: RouterDecision, + step_result: StepAgentResult, + tool_result: ToolResult | None, + ) -> ExecutionFinalizeState: + return TurnFinalizer.finalize( + tenant_id, + chat_session, + active_skill, + router_decision, + step_result, + tool_result, + current_step_allows_handoff=self._current_step_allows_human_handoff, + create_handoff=self._create_human_handoff_request, + record_event=self.events.record, + should_complete=self._should_complete_skill, + complete_skill=self._complete_active_skill, ) - if selected_general_tool: - scoped_tools.extend( - tool - for tool in general_skill_tools - if str(getattr(tool, "name", "") or "") == selected_general_tool - ) - return scoped_tools - def _selected_general_skill_tool_name( + def _create_human_handoff_request( self, - user_message: str | None, - model_config: ModelConfig | None, - agent_id: str | None, - general_skill_tools: list[Tool], - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> str | None: - message = str(user_message or "").strip() - if not message or not model_config or not general_skill_tools: - return None - allowed_slugs = { - str(getattr(tool, "name", "") or "").removeprefix(GENERAL_SKILL_TOOL_PREFIX) - for tool in general_skill_tools - if str(getattr(tool, "name", "") or "").startswith(GENERAL_SKILL_TOOL_PREFIX) - } - allowed_slugs = {slug for slug in allowed_slugs if slug} - if not allowed_slugs: - return None - candidates = [ - skill - for skill in self._list_published_general_skills(model_config.tenant_id, agent_id) - if skill.slug in allowed_slugs - ] - if not candidates: - return None - try: - selection = self.general_skill_selector.decide( - message, - candidates, - model_config, - conversation_context, - memory_context, - ) - except LLMError: - return None - if not selection.use_general_skill or not selection.selected_slug: - return None - if selection.selected_slug not in allowed_slugs: - return None - return f"{GENERAL_SKILL_TOOL_PREFIX}{selection.selected_slug}" + tenant_id: str, + chat_session: ChatSession, + active_skill: Skill | None, + step_result: StepAgentResult, + ) -> HumanHandoffRequest: + return HumanHandoffService(self.db, self.events).create( + tenant_id, + chat_session, + step_result, + current_step_resolver=lambda: ( + self._current_skill_step(active_skill, chat_session.active_step_id) + if active_skill + else None + ), + assignee_resolver=self._human_handoff_assignee_user_id, + context_summary=self._human_handoff_context_summary, + pending_question=self._human_handoff_pending_question, + ) def _apply_step_result( self, @@ -4426,858 +768,192 @@ def _sync_awaiting_input_from_step_result( if is_waiting_reply and missing_fields: previous = ( chat_session.awaiting_input_json - if isinstance(chat_session.awaiting_input_json, dict) - else {} - ) - awaiting_input = { - "skill_id": source_skill_id, - "step_id": source_step_id, - "expected_fields": missing_fields, - "question_summary": str(step_result.reply or "").strip() or None, - } - if previous.get("task_id"): - awaiting_input["task_id"] = previous["task_id"] - chat_session.awaiting_input_json = awaiting_input - chat_session.last_agent_question = awaiting_input["question_summary"] - return - - should_clear = bool( - step_result.next_step_id - or step_result.tool_call - or step_result.is_step_completed - or not missing_fields - ) - awaiting = chat_session.awaiting_input_json - if not should_clear or not isinstance(awaiting, dict): - return - if awaiting.get("skill_id") not in {None, source_skill_id}: - return - if awaiting.get("step_id") not in {None, source_step_id}: - return - task_id = awaiting.get("task_id") - chat_session.awaiting_input_json = {"task_id": task_id} if task_id else None - chat_session.last_agent_question = None - - def _change_active_step( - self, - tenant_id: str, - chat_session: ChatSession, - next_step_id: str, - *, - reason: str | None = None, - ) -> None: - previous_step = chat_session.active_step_id - chat_session.active_step_id = next_step_id - if previous_step == next_step_id: - return - payload: dict[str, Any] = { - "from_skill_id": chat_session.active_skill_id, - "to_skill_id": chat_session.active_skill_id, - "from_step_id": previous_step, - "to_step_id": next_step_id, - } - if reason: - payload["reason"] = reason - self.events.record(tenant_id, chat_session.id, "skill_step_changed", payload) - - def _graph_pending_steps(self, chat_session: ChatSession) -> list[str]: - value = (chat_session.slots_json or {}).get(GRAPH_PENDING_STEPS_SLOT) - return LegacyGraphRules.normalize_pending_steps(value) - - def _store_graph_pending_steps( - self, - tenant_id: str, - chat_session: ChatSession, - pending_steps: list[str], - ) -> None: - slots = dict(chat_session.slots_json or {}) - normalized = LegacyGraphRules.normalize_pending_steps(pending_steps) - if normalized: - slots[GRAPH_PENDING_STEPS_SLOT] = normalized - else: - slots.pop(GRAPH_PENDING_STEPS_SLOT, None) - chat_session.slots_json = slots - self.events.record( - tenant_id, - chat_session.id, - "graph_pending_steps_updated", - {"pending_step_ids": normalized}, - ) - - def _queue_graph_sibling_steps( - self, - tenant_id: str, - chat_session: ChatSession, - active_skill: Skill, - source_step_id: str | None, - selected_step_id: str, - ) -> None: - if not source_step_id: - return - outgoing = self._graph_outgoing_edges(active_skill).get(source_step_id) or [] - sibling_steps = LegacyGraphRules.sibling_steps_from_edges( - outgoing, - selected_step_id, - self._edge_condition, - ) - if not sibling_steps: - return - pending_steps = self._graph_pending_steps(chat_session) - for step_id in sibling_steps: - if step_id not in pending_steps: - pending_steps.append(step_id) - self._store_graph_pending_steps(tenant_id, chat_session, pending_steps) - - def _edge_condition(self, edge: dict[str, Any]) -> str: - return LegacyGraphRules.edge_condition(edge) - - def _activate_next_pending_graph_step( - self, - tenant_id: str, - chat_session: ChatSession, - active_skill: Skill, - *, - reason: str, - ) -> bool: - pending_steps = self._graph_pending_steps(chat_session) - while pending_steps: - next_step_id = pending_steps.pop(0) - if not self._skill_has_step(active_skill, next_step_id): - continue - self._store_graph_pending_steps(tenant_id, chat_session, pending_steps) - self._change_active_step(tenant_id, chat_session, next_step_id, reason=reason) - return True - self._store_graph_pending_steps(tenant_id, chat_session, []) - return False - - def _skill_has_step(self, skill: Skill, step_id: str | None) -> bool: - return LegacyGraphRules.has_step(skill.content_json or {}, step_id) - - def _step_actions(self, step: dict[str, Any]) -> list[str]: - return LegacyGraphRules.step_actions(step) - - def _record_tool_result_in_slots( - self, - chat_session: ChatSession, - tool_call: ToolCall, - tool_result: ToolResult, - ) -> None: - chat_session.slots_json = ToolReplayPolicy.record_result( - chat_session.slots_json or {}, - tool_call, - tool_result, - history_reader=self._tool_call_history, - call_signature=self._tool_call_signature, - history_signature=self._tool_history_signature, - ) - - def _tool_call_history(self, slots: dict[str, Any]) -> list[dict[str, Any]]: - history = slots.get(TOOL_CALL_HISTORY_SLOT) - if not isinstance(history, list): - return [] - return [item for item in history if isinstance(item, dict)] - - def _tool_history_signature(self, item: dict[str, Any]) -> str: - return self._tool_signature( - str(item.get("tool_name") or ""), - item.get("arguments") if isinstance(item.get("arguments"), dict) else {}, - ) - - def _tool_call_signature(self, tool_call: ToolCall) -> str: - return self._tool_signature(tool_call.name, tool_call.arguments) - - def _tool_signature(self, tool_name: str, arguments: dict[str, Any]) -> str: - return ToolReplayPolicy.signature(tool_name, arguments) - - def _tool_idempotency_config(self, tool: Tool) -> tuple[bool | None, list[str] | None]: - return ToolReplayPolicy.configuration( - tool.config_json if isinstance(tool.config_json, dict) else {}, - tool.input_schema if isinstance(tool.input_schema, dict) else {}, - enabled_parser=self._idempotency_enabled_value, - ) - - def _idempotency_enabled_value(self, value: object) -> bool | None: - return ToolReplayPolicy.enabled_value(value) - - def _tool_requires_idempotent_replay( - self, tenant_id: str, tool_call: ToolCall - ) -> tuple[bool, list[str] | None]: - if tool_call.name.startswith(GENERAL_SKILL_TOOL_PREFIX): - return False, None - if not hasattr(self.db, "exec"): - return False, None - statement = select(Tool).where(Tool.tenant_id == tenant_id, Tool.name == tool_call.name) - no_autoflush = getattr(self.db, "no_autoflush", None) - if no_autoflush is None: - tool = self.db.exec(statement).first() - else: - with no_autoflush: - tool = self.db.exec(statement).first() - if not tool: - return False, None - configured, key_fields = self._tool_idempotency_config(tool) - if configured is not None: - return configured, key_fields - method = str(tool.method or "").upper() - if not ToolReplayPolicy.default_replay_enabled(method): - return False, None - return True, key_fields - - def _idempotency_arguments( - self, arguments: dict[str, Any], key_fields: list[str] | None - ) -> dict[str, Any]: - return ToolReplayPolicy.arguments(arguments, key_fields) - - def _previous_successful_side_effect_tool_result( - self, - tenant_id: str, - session_id: str, - tool_call: ToolCall, - ) -> tuple[ToolResult, str] | None: - replay_enabled, key_fields = self._tool_requires_idempotent_replay(tenant_id, tool_call) - if not replay_enabled: - return None - target_signature = self._tool_signature( - tool_call.name, - self._idempotency_arguments(tool_call.arguments, key_fields), - ) - rows = self.db.exec( - select(AgentEvent) - .where( - AgentEvent.tenant_id == tenant_id, - AgentEvent.session_id == session_id, - AgentEvent.event_type == "tool_call_finished", - ) - .order_by(AgentEvent.created_at.desc()) - .limit(200) - ).all() - for event in rows: - payload = event.payload_json or {} - if payload.get("success") is not True: - continue - payload_tool_name = str(payload.get("tool_name") or "").strip() - if payload_tool_name != tool_call.name: - continue - payload_tool_call = ( - payload.get("tool_call") if isinstance(payload.get("tool_call"), dict) else {} - ) - payload_arguments = payload_tool_call.get("arguments") - if not isinstance(payload_arguments, dict): - payload_arguments = ( - payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {} - ) - replay_arguments = self._idempotency_arguments(payload_arguments, key_fields) - if self._tool_signature(payload_tool_name, replay_arguments) != target_signature: - continue - replay_data = payload.get("data") - if isinstance(replay_data, dict): - replay_data = { - **replay_data, - "idempotent_replay": True, - "replayed_from_event_id": event.id, - } - else: - replay_data = { - "result": replay_data, - "idempotent_replay": True, - "replayed_from_event_id": event.id, - } - return ToolResult(tool_name=tool_call.name, success=True, data=replay_data), event.id - return None - - def _execute_tool_call( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - tool_call: ToolCall, - tool_call_id: str | None = None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> ToolResult: - if ( - not tool_call.name.startswith(GENERAL_SKILL_TOOL_PREFIX) - and chat_session.agent_id - and tool_call.name - not in { - row.name - for row in self._list_enabled_tools(request.tenant_id, chat_session.agent_id) - } - ): - tool_result = ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError(code="NOT_ALLOWED", message="当前员工未启用该工具。"), - ) - started_payload = tool_call.model_dump(mode="json") - if tool_call_id: - started_payload["tool_call_id"] = tool_call_id - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_started", - started_payload, - ) - finished_payload = tool_result.model_dump(mode="json") - if tool_call_id: - finished_payload["tool_call_id"] = tool_call_id - finished_payload["tool_call"] = tool_call.model_dump(mode="json") - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_finished", - finished_payload, - ) - self.db.commit() - self.db.refresh(chat_session) - return tool_result - - replayed = self._previous_successful_side_effect_tool_result( - request.tenant_id, - chat_session.id, - tool_call, - ) - if replayed: - tool_result, replayed_event_id = replayed - replay_payload = { - "tool_name": tool_call.name, - "tool_call": tool_call.model_dump(mode="json"), - "replayed_from_event_id": replayed_event_id, - } - if tool_call_id: - replay_payload["tool_call_id"] = tool_call_id - self.events.record( - request.tenant_id, chat_session.id, "tool_call_reused", replay_payload - ) - finished_payload = tool_result.model_dump(mode="json") - if tool_call_id: - finished_payload["tool_call_id"] = tool_call_id - finished_payload["tool_call"] = tool_call.model_dump(mode="json") - finished_payload["idempotent_replay"] = True - finished_payload["replayed_from_event_id"] = replayed_event_id - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_finished", - finished_payload, - ) - self.db.commit() - self.db.refresh(chat_session) - return tool_result - - started_payload = tool_call.model_dump(mode="json") - if tool_call_id: - started_payload["tool_call_id"] = tool_call_id - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_started", - started_payload, - ) - self.db.commit() - self.db.refresh(chat_session) - if tool_call.name.startswith(GENERAL_SKILL_TOOL_PREFIX): - if not chat_session.active_skill_id: - tool_result = ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError( - code="GENERAL_SKILL_REQUIRES_SCENE_SKILL", - message="通用技能只能作为当前场景技能的辅助工具调用。", - ), - ) - finished_payload = tool_result.model_dump(mode="json") - if tool_call_id: - finished_payload["tool_call_id"] = tool_call_id - finished_payload["tool_call"] = tool_call.model_dump(mode="json") - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_finished", - finished_payload, - ) - self.db.commit() - self.db.refresh(chat_session) - return tool_result - tool_result = self._execute_general_skill_tool_call( - request, - chat_session, - tool_call, - chat_session.agent_id, - stream_events=stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - else: - tool_result = self.tool_executor.execute( - request.tenant_id, - tool_call, - chat_session.active_skill_id, - chat_session.agent_id, - ) - finished_payload = tool_result.model_dump(mode="json") - if tool_call_id: - finished_payload["tool_call_id"] = tool_call_id - finished_payload["tool_call"] = tool_call.model_dump(mode="json") - self.events.record( - request.tenant_id, - chat_session.id, - "tool_call_finished", - finished_payload, - ) - self.db.commit() - self.db.refresh(chat_session) - return tool_result - - def _execute_general_skill_tool_call( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - tool_call: ToolCall, - agent_id: str | None, - stream_events: list[tuple[str, dict[str, object]]] | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> ToolResult: - raw_operation = tool_call.arguments.get("operation") - operation = ( - "execute" - if raw_operation is None - else str(raw_operation).strip().lower() - ) - if operation not in {"read", "execute"}: - return ToolResult( - tool_name=tool_call.name, - success=False, - data={"operation": operation}, - error=ToolError( - code="INVALID_GENERAL_SKILL_OPERATION", - message="通用技能 operation 只能是 read 或 execute。", - ), + if isinstance(chat_session.awaiting_input_json, dict) + else {} ) - return LegacyGeneralSkillAction(getattr(self, "events", None)).execute_tool_call( - request, - chat_session, - tool_call, - agent_id, - stream_events, - conversation_context, - memory_context, - tool_prefix=GENERAL_SKILL_TOOL_PREFIX, - list_skills=self._list_published_general_skills, - model_resolver=self._get_request_model, - precondition_error_type=AgentLoopPreconditionError, - validator=self._validate_general_skill_tool_match, - runner=lambda selected_skill, *args, **kwargs: self._run_general_skill_operation( - self._general_skill_runtime_snapshot(request, chat_session, selected_skill), - *args, - operation=operation, - **kwargs, - ), - ) + awaiting_input = { + "skill_id": source_skill_id, + "step_id": source_step_id, + "expected_fields": missing_fields, + "question_summary": str(step_result.reply or "").strip() or None, + } + if previous.get("task_id"): + awaiting_input["task_id"] = previous["task_id"] + chat_session.awaiting_input_json = awaiting_input + chat_session.last_agent_question = awaiting_input["question_summary"] + return - def _validate_general_skill_tool_match( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - tool_call: ToolCall, - requested_skill: GeneralSkill, - query: str, - model_config: ModelConfig, - agent_id: str | None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> ToolResult | None: - return LegacyGeneralSkillAction(getattr(self, "events", None)).validate_tool_match( - request, - chat_session, - tool_call, - requested_skill, - query, - model_config, - agent_id, - conversation_context, - memory_context, - validated_calls=self._validated_general_skill_calls, - call_key=self._general_skill_call_key, - list_skills=self._list_published_general_skills, - selector=lambda *args, **kwargs: self.general_skill_selector.decide( - *args, **kwargs - ), + should_clear = bool( + step_result.next_step_id + or step_result.tool_call + or step_result.is_step_completed + or not missing_fields ) + awaiting = chat_session.awaiting_input_json + if not should_clear or not isinstance(awaiting, dict): + return + if awaiting.get("skill_id") not in {None, source_skill_id}: + return + if awaiting.get("step_id") not in {None, source_step_id}: + return + task_id = awaiting.get("task_id") + chat_session.awaiting_input_json = {"task_id": task_id} if task_id else None + chat_session.last_agent_question = None - def _general_skill_call_key( - self, - session_id: str, - tool_name: str, - query: str, - ) -> tuple[str, str, str]: - return LegacyGeneralSkillAction.call_key(session_id, tool_name, query) - - def _advance_after_successful_tool( + def _change_active_step( self, tenant_id: str, chat_session: ChatSession, - active_skill: Skill | None, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> bool: - if ( - not active_skill - or not step_result.tool_call - or step_result.tool_call.name.startswith(GENERAL_SKILL_TOOL_PREFIX) - or ( - step_result.next_step_id and step_result.next_step_id != chat_session.active_step_id - ) - or not tool_result - or not tool_result.success - ): - return False - - next_step_id = self._next_step_after_successful_tool( - active_skill, chat_session.active_step_id, chat_session.slots_json or {} - ) - if not next_step_id: - return False - + next_step_id: str, + *, + reason: str | None = None, + ) -> None: previous_step = chat_session.active_step_id chat_session.active_step_id = next_step_id - step_result.next_step_id = next_step_id - self.events.record( - tenant_id, - chat_session.id, - "skill_step_changed", - { - "from_skill_id": chat_session.active_skill_id, - "to_skill_id": chat_session.active_skill_id, - "from_step_id": previous_step, - "to_step_id": next_step_id, - "reason": "tool_completed", - }, - ) - return True - - def _next_step_after_successful_tool( - self, skill: Skill, active_step_id: str | None, slots: dict[str, Any] - ) -> str | None: - if not active_step_id: - return None - for step in self._next_steps_from_graph(skill, active_step_id): - step_id = str(step.get("step_id") or "") - if not step_id: - continue - expected = [str(field) for field in step.get("expected_user_info", [])] - if any(not self._skill_slot_satisfied(slots, field) for field in expected): - return step_id - actions = self._step_actions(step) - if self._actions_allow_final_reply(actions) or any( - action.startswith("call_tool:") for action in actions - ): - return step_id - return None + if previous_step == next_step_id: + return + payload: dict[str, Any] = { + "from_skill_id": chat_session.active_skill_id, + "to_skill_id": chat_session.active_skill_id, + "from_step_id": previous_step, + "to_step_id": next_step_id, + } + if reason: + payload["reason"] = reason + self.events.record(tenant_id, chat_session.id, "skill_step_changed", payload) - def _router_decision_from_reflection( - self, - reflection: ReflectionDecision, - chat_session: ChatSession, - skills: list[Skill], - previous_decision: RouterDecision, - completed_skill_ids_this_turn: set[str] | None = None, - ) -> RouterDecision | None: - return LegacyReflectionPolicy.router_decision( - reflection, - chat_session, - skills, - previous_decision, - completed_skill_ids_this_turn, - record_event=lambda *args, **kwargs: self.events.record(*args, **kwargs), - first_step_id=self._first_step_id, - ) + def _graph_pending_steps(self, chat_session: ChatSession) -> list[str]: + value = (chat_session.slots_json or {}).get(GRAPH_PENDING_STEPS_SLOT) + return GraphRules.normalize_pending_steps(value) - def _tool_call_from_reflection( + def _store_graph_pending_steps( self, - reflection: ReflectionDecision, + tenant_id: str, chat_session: ChatSession, - tools: list[Tool], - user_message: str | None = None, - ) -> ToolCall | None: - return LegacyReflectionPolicy.tool_call( - reflection, - chat_session, - tools, - user_message, - general_skill_tool_prefix=GENERAL_SKILL_TOOL_PREFIX, - build_arguments=self._build_tool_arguments_from_slots, - slot_has_value=self._slot_has_value, - ) - - def _build_tool_arguments_from_slots(self, tool: Tool, slots: dict[str, Any]) -> dict[str, Any]: - schema = tool.input_schema or {} - properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {} - fields = [str(field) for field in properties] - for field in schema.get("required", []): - if str(field) not in fields: - fields.append(str(field)) - - arguments: dict[str, Any] = {} - for field in fields: - if self._slot_has_value(slots, field): - arguments[field] = slots[field] - used_signatures = { - self._tool_history_signature(item) for item in self._tool_call_history(slots) - } - if self._tool_signature(tool.name, arguments) in used_signatures: - return {} - return arguments - - def _should_try_reflection( - self, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> bool: - if ( - tool_result - and isinstance(tool_result.data, dict) - and tool_result.data.get("operation") == "read" - ): - return False - return LegacyReflectionPolicy.should_try( - router_decision, - step_result, - tool_result, - predicate=action_needs_reflection, - ) - - def _first_step_id(self, skill: Skill) -> str | None: - content = skill.content_json or {} - start_node_id = str(content.get("start_node_id") or "").strip() - if start_node_id and self._skill_has_step(skill, start_node_id): - return start_node_id - steps = self._skill_steps(skill) - first_step = steps[0] if steps and isinstance(steps[0], dict) else None - return first_step.get("step_id") if first_step else None - - def _skill_steps(self, skill: Skill) -> list[dict[str, Any]]: - return LegacyGraphRules.steps_from_nodes(self._ordered_skill_nodes(skill)) - - def _skill_nodes(self, skill: Skill) -> list[dict[str, Any]]: - return LegacyGraphRules.nodes(skill.content_json or {}) - - def _ordered_skill_nodes(self, skill: Skill) -> list[dict[str, Any]]: - content = skill.content_json or {} - return LegacyGraphRules.ordered_nodes( - content, - nodes=self._skill_nodes(skill), - outgoing=self._graph_outgoing_edges(skill), - ) - - def _graph_outgoing_edges(self, skill: Skill) -> dict[str, list[dict[str, Any]]]: - return LegacyGraphRules.outgoing_edges(skill.content_json or {}) - - def _next_steps_from_graph( - self, skill: Skill, active_step_id: str | None - ) -> list[dict[str, Any]]: - if not active_step_id: - return [] - return LegacyGraphRules.next_steps_from_parts( - self._skill_nodes(skill), - self._graph_outgoing_edges(skill).get(active_step_id, []), - ) - - def _default_next_step(self, skill: Skill, active_step_id: str | None) -> dict[str, Any] | None: - if not active_step_id: - return None - return LegacyGraphRules.default_next_step_from_parts( - self._skill_nodes(skill), - self._graph_outgoing_edges(skill).get(active_step_id, []), - ) - - def _get_or_create_session(self, request: ChatTurnRequest) -> ChatSession: - session_id = request.session_id or new_id("session") - chat_session = self.db.get(ChatSession, session_id) - if not chat_session: - chat_session = ChatSession( - id=session_id, - tenant_id=request.tenant_id, - user_id=request.user_id, - agent_id=request.agent_id, - ) - self.db.add(chat_session) - self.db.flush() - elif not chat_session.agent_id and request.agent_id: - chat_session.agent_id = request.agent_id - return chat_session - - def _finish_stale_completed_skill( - self, tenant_id: str, chat_session: ChatSession, skills: list[Skill] + pending_steps: list[str], ) -> None: - if chat_session.skill_stack_json or chat_session.resume_after_answer_json: - chat_session.skill_stack_json = [] - chat_session.resume_after_answer_json = None - chat_session.updated_at = utc_now() - active_skill = next( - (skill for skill in skills if skill.skill_id == chat_session.active_skill_id), None + slots = dict(chat_session.slots_json or {}) + normalized = GraphRules.normalize_pending_steps(pending_steps) + if normalized: + slots[GRAPH_PENDING_STEPS_SLOT] = normalized + else: + slots.pop(GRAPH_PENDING_STEPS_SLOT, None) + chat_session.slots_json = slots + self.events.record( + tenant_id, + chat_session.id, + "graph_pending_steps_updated", + {"pending_step_ids": normalized}, ) - if active_skill and self._is_terminal_skill_state(active_skill, chat_session): - self._complete_active_skill( - tenant_id, chat_session, active_skill, "stale_terminal_state" - ) - def _should_complete_skill( + def _queue_graph_sibling_steps( self, - skill: Skill | None, + tenant_id: str, chat_session: ChatSession, - step_result: StepAgentResult, - tool_result: ToolResult | None, - ) -> bool: - if not skill or not step_result.is_step_completed: - return False - if tool_result and not tool_result.success: - return False - if ( - tool_result - and tool_result.success - and self._current_step_can_finish_after_tool(skill, chat_session) - ): - return True - if self._graph_pending_steps(chat_session): - return False - if self._is_answer_ready_skill_state(skill, chat_session): - return True - if self._is_terminal_skill_state(skill, chat_session): - return True - if not step_result.next_step_id and not step_result.tool_call: - return True - if self._graph_flow_has_unfinished_work(skill, chat_session, step_result): - return False - return self._is_terminal_skill_state(skill, chat_session) - - def _is_terminal_skill_state(self, skill: Skill, chat_session: ChatSession) -> bool: - return self._is_terminal_skill_position( - skill, chat_session.active_step_id, chat_session.slots_json or {} - ) - - def _is_answer_ready_skill_state(self, skill: Skill, chat_session: ChatSession) -> bool: - step = self._current_skill_step(skill, chat_session.active_step_id) - if not step: - return False - actions = self._step_actions(step) - if not self._actions_allow_final_reply(actions): - return False - required = [str(field) for field in (skill.content_json or {}).get("required_info", [])] - return all( - self._skill_slot_satisfied(chat_session.slots_json or {}, field) for field in required - ) - - def _current_step_expected_info_satisfied( - self, skill: Skill, chat_session: ChatSession - ) -> bool: - step = self._current_skill_step(skill, chat_session.active_step_id) - if not step: - return False - expected = [str(field) for field in step.get("expected_user_info", [])] - return all( - self._skill_slot_satisfied(chat_session.slots_json or {}, field) for field in expected + active_skill: Skill, + source_step_id: str | None, + selected_step_id: str, + ) -> None: + if not source_step_id: + return + outgoing = self._graph_outgoing_edges(active_skill).get(source_step_id) or [] + sibling_steps = GraphRules.sibling_steps_from_edges( + outgoing, + selected_step_id, + self._edge_condition, ) + if not sibling_steps: + return + pending_steps = self._graph_pending_steps(chat_session) + for step_id in sibling_steps: + if step_id not in pending_steps: + pending_steps.append(step_id) + self._store_graph_pending_steps(tenant_id, chat_session, pending_steps) - def _graph_flow_has_unfinished_work( + def _edge_condition(self, edge: dict[str, Any]) -> str: + return GraphRules.edge_condition(edge) + + def _activate_next_pending_graph_step( self, - skill: Skill | None, + tenant_id: str, chat_session: ChatSession, - step_result: StepAgentResult | None = None, + active_skill: Skill, + *, + reason: str, ) -> bool: - if not skill or chat_session.active_skill_id != skill.skill_id: - return False - if self._graph_pending_steps(chat_session): - return True - if ( - step_result - and step_result.next_step_id - and str(step_result.next_step_id) == str(chat_session.active_step_id) - ): + pending_steps = self._graph_pending_steps(chat_session) + while pending_steps: + next_step_id = pending_steps.pop(0) + if not self._skill_has_step(active_skill, next_step_id): + continue + self._store_graph_pending_steps(tenant_id, chat_session, pending_steps) + self._change_active_step(tenant_id, chat_session, next_step_id, reason=reason) return True - if not chat_session.active_step_id: - return False - return bool(self._graph_outgoing_edges(skill).get(chat_session.active_step_id)) + self._store_graph_pending_steps(tenant_id, chat_session, []) + return False - def _is_terminal_skill_frame(self, skill: Skill, frame: dict[str, Any]) -> bool: - return self._is_terminal_skill_position( - skill, - str(frame.get("step_id") or ""), - frame.get("slots") if isinstance(frame.get("slots"), dict) else {}, - ) + def _skill_has_step(self, skill: Skill, step_id: str | None) -> bool: + return GraphRules.has_step(skill.content_json or {}, step_id) - def _is_terminal_skill_position( - self, skill: Skill, active_step_id: str | None, slots: dict[str, Any] - ) -> bool: - if not active_step_id: - return False + def _first_step_id(self, skill: Skill) -> str | None: content = skill.content_json or {} - terminal_node_ids = { - str(node_id) for node_id in content.get("terminal_node_ids", []) - } - if active_step_id not in terminal_node_ids: - return False - return LegacyGraphRules.terminal_position_from_step( + start_node_id = str(content.get("start_node_id") or "").strip() + if start_node_id and self._skill_has_step(skill, start_node_id): + return start_node_id + steps = self._skill_steps(skill) + first_step = steps[0] if steps and isinstance(steps[0], dict) else None + return first_step.get("step_id") if first_step else None + + def _skill_steps(self, skill: Skill) -> list[dict[str, Any]]: + return GraphRules.steps_from_nodes(self._ordered_skill_nodes(skill)) + + def _skill_nodes(self, skill: Skill) -> list[dict[str, Any]]: + return GraphRules.nodes(skill.content_json or {}) + + def _ordered_skill_nodes(self, skill: Skill) -> list[dict[str, Any]]: + content = skill.content_json or {} + return GraphRules.ordered_nodes( content, - active_step_id, - slots, - self._current_skill_step(skill, active_step_id), - self._skill_slot_satisfied, - self._step_actions, + nodes=self._skill_nodes(skill), + outgoing=self._graph_outgoing_edges(skill), ) - def _current_skill_step( - self, skill: Skill, active_step_id: str | None - ) -> dict[str, Any] | None: + def _graph_outgoing_edges(self, skill: Skill) -> dict[str, list[dict[str, Any]]]: + return GraphRules.outgoing_edges(skill.content_json or {}) + + def _default_next_step(self, skill: Skill, active_step_id: str | None) -> dict[str, Any] | None: if not active_step_id: return None - return LegacyGraphRules.current_step_from_steps( - self._skill_steps(skill), active_step_id + return GraphRules.default_next_step_from_parts( + self._skill_nodes(skill), + self._graph_outgoing_edges(skill).get(active_step_id, []), ) - def _current_step_can_finish_after_tool(self, skill: Skill, chat_session: ChatSession) -> bool: - step = self._current_skill_step(skill, chat_session.active_step_id) - if not step: - return False - actions = self._step_actions(step) - if not self._actions_allow_final_reply(actions): - return False - expected = [str(field) for field in step.get("expected_user_info", [])] - return all( - self._skill_slot_satisfied(chat_session.slots_json or {}, field) for field in expected - ) + def _get_or_create_session(self, request: ChatTurnRequest) -> ChatSession: + session_id = request.session_id or new_id("session") + chat_session = self.db.get(ChatSession, session_id) + if not chat_session: + chat_session = ChatSession( + id=session_id, + tenant_id=request.tenant_id, + user_id=request.user_id, + agent_id=request.agent_id, + ) + self.db.add(chat_session) + self.db.flush() + elif not chat_session.agent_id and request.agent_id: + chat_session.agent_id = request.agent_id + return chat_session - def _actions_allow_final_reply(self, actions: list[str]) -> bool: - return LegacyGraphRules.actions_allow_final_reply(actions) + def _current_skill_step( + self, skill: Skill, active_step_id: str | None + ) -> dict[str, Any] | None: + if not active_step_id: + return None + return GraphRules.current_step_from_steps(self._skill_steps(skill), active_step_id) def _skill_slot_satisfied(self, slots: dict[str, Any], field: str) -> bool: - return LegacyGraphRules.slot_satisfied(slots, field) - - def _slot_has_value(self, slots: dict[str, Any], field: str) -> bool: - return LegacyGraphRules.slot_has_value(slots, field) - - def _complete_active_skill( - self, tenant_id: str, chat_session: ChatSession, skill: Skill, reason: str - ) -> None: - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - self.runtime.complete_current_skill(chat_session) - self.events.record( - tenant_id, - chat_session.id, - "skill_completed", - { - "skill_id": before_skill or skill.skill_id, - "step_id": before_step, - "reason": reason, - "resumed_skill_id": chat_session.active_skill_id, - "resumed_step_id": chat_session.active_step_id, - }, - ) + return GraphRules.slot_satisfied(slots, field) def _get_request_model( self, @@ -5308,11 +984,6 @@ def _get_persona_prompt(self, tenant_id: str, agent_id: str | None = None) -> st row = self.db.get(PersonaConfig, tenant_id) return row.system_prompt if row else None - def _get_reflection_max_rounds(self, tenant_id: str) -> int: - row = self.db.get(UIConfig, tenant_id) - value = row.reflection_max_rounds if row else DEFAULT_REFLECTION_MAX_ROUNDS - return max(0, min(int(value), REFLECTION_MAX_ROUNDS_LIMIT)) - def _get_agent_loop_max_actions(self, tenant_id: str) -> int: if not hasattr(self.db, "get"): return MAX_TOOL_ACTIONS_PER_TURN @@ -5323,166 +994,6 @@ def _get_agent_loop_max_actions(self, tenant_id: str) -> int: def _list_published_skills(self, tenant_id: str, agent_id: str | None = None) -> list[Skill]: return visible_published_skills(self.db, tenant_id, agent_id) - def _general_skill_runtime_snapshot( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - skill: GeneralSkill, - turn_id: str | None = None, - ) -> GeneralSkillRuntimeSnapshot: - catalog = getattr(self, "general_skill_catalog", None) - if catalog is None: - catalog = LocalGeneralSkillCatalog(self.db) - self.general_skill_catalog = catalog - context = CapabilityContext( - request_id=new_id("req"), - tenant_id=request.tenant_id, - agent_id=str(chat_session.agent_id or request.agent_id or "legacy-overall"), - user_id=str(request.user_id or chat_session.user_id or "anonymous"), - session_id=chat_session.id, - turn_id=str(turn_id or request.client_turn_id or new_id("turn")), - channel=request.channel, - ) - resource_ref = resource_ref_from_row( - skill, - catalog_binding_id=catalog.provider_id, - ) - package = catalog.get_package(context, resource_ref) - if package is None or ( - package.package_id != resource_ref.package_id - or package.version != resource_ref.version - or package.digest != resource_ref.digest - or package.package_contract_version - != resource_ref.package_contract_version - ): - raise AgentLoopPreconditionError( - "general_skill_content_unavailable", - "通用技能内容不可用或运行前已发生变化,请重试。", - ) - return runtime_snapshot_from_package(skill, package) - - def _list_published_general_skills( - self, tenant_id: str, agent_id: str | None = None - ) -> list[GeneralSkill]: - agent = self._get_agent_profile(tenant_id, agent_id) - if not agent or agent.is_overall: - rows = self.db.exec( - select(GeneralSkill).where( - GeneralSkill.tenant_id == tenant_id, - GeneralSkill.status == "published", - ) - ).all() - return [ - row - for row in rows - if is_open_gallery_resource(self.db, tenant_id, "general_skill", row) - ] - - bindings = self.db.exec( - select(AgentResourceBinding).where( - AgentResourceBinding.tenant_id == tenant_id, - AgentResourceBinding.agent_id == agent.id, - AgentResourceBinding.resource_type == "general_skill", - AgentResourceBinding.status == "active", - ) - ).all() - visible: list[GeneralSkill] = [] - for binding in bindings: - row = self.db.get(GeneralSkill, binding.resource_id) - if not row or row.tenant_id != tenant_id or row.status != "published": - continue - if is_bound_resource_visible_for_agent( - self.db, - tenant_id, - "general_skill", - row, - binding, - ): - visible.append(row) - return visible - - def _select_general_skill( - self, - message: str, - model_config: ModelConfig, - agent_id: str | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[GeneralSkill, GeneralSkillSelection] | None: - skill, selection = self._select_general_capability( - message, - model_config, - agent_id, - conversation_context, - memory_context, - ) - if skill is None: - return None - return skill, selection - - def _select_general_capability( - self, - message: str, - model_config: ModelConfig, - agent_id: str | None = None, - conversation_context: dict[str, object] | None = None, - memory_context: list[dict[str, object]] | None = None, - ) -> tuple[GeneralSkill | None, GeneralSkillSelection]: - general_skills = self._list_published_general_skills(model_config.tenant_id, agent_id) - try: - selection = self.general_skill_selector.decide( - message, - general_skills, - model_config, - conversation_context, - memory_context, - ) - except LLMError as exc: - return None, GeneralSkillSelection(reason=f"Capability selection failed: {exc}") - if not selection.use_general_skill or not selection.selected_slug: - return None, selection - skill = next( - (item for item in general_skills if item.slug == selection.selected_slug), None - ) - if not skill: - return None, selection.model_copy( - update={"use_general_skill": False, "selected_slug": None} - ) - return skill, selection - - def _list_enabled_tools(self, tenant_id: str, agent_id: str | None = None) -> list[Tool]: - return visible_tool_rows(self.db, tenant_id, agent_id, include_inactive=False) - - def _tools_with_general_skills( - self, tenant_id: str, tools: list[Tool], agent_id: str | None = None - ) -> list[Any]: - combined: list[Any] = list(tools) - for skill in self._list_published_general_skills(tenant_id, agent_id): - combined.append( - SimpleNamespace( - enabled=True, - name=f"{GENERAL_SKILL_TOOL_PREFIX}{skill.slug}", - display_name=skill.name, - description=( - f"通用技能:{skill.description or skill.name}。" - "仅当当前子任务与该名称、描述和能力边界直接匹配时才能调用;" - "不得把它作为场景工具、已有工具结果、知识查询或追问用户的兜底替代。" - ), - input_schema={ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "传给通用技能的自然语言任务或问题。", - } - }, - "required": ["query"], - }, - allowed_skills_json=[], - ) - ) - return combined - def _get_agent_profile(self, tenant_id: str, agent_id: str | None) -> AgentProfile | None: if not agent_id: return None @@ -5491,13 +1002,6 @@ def _get_agent_profile(self, tenant_id: str, agent_id: str | None) -> AgentProfi return None return row - def _agent_requires_resource_filter(self, tenant_id: str, agent_id: str | None) -> bool: - agent = self._get_agent_profile(tenant_id, agent_id) - return bool(agent and not agent.is_overall) - - def _agent_visible_knowledge_base_ids(self, tenant_id: str, agent_id: str | None) -> list[str]: - return visible_knowledge_base_ids(self.db, tenant_id, agent_id) - def _get_active_skill( self, tenant_id: str, skill_id: str | None, agent_id: str | None = None ) -> Skill | None: @@ -5603,39 +1107,6 @@ def keep_frame(frame: object) -> bool: ) return changed - def _should_record_runtime_event_after_prune( - self, - router_decision: RouterDecision, - chat_session: ChatSession, - skills: list[Skill], - state_pruned: bool, - ) -> bool: - if not state_pruned: - return True - if chat_session.active_skill_id: - return True - target_skill_id = str(router_decision.target_skill_id or "").strip() - if not target_skill_id: - return True - return target_skill_id in {skill.skill_id for skill in skills} - - def _recent_messages(self, chat_session: ChatSession, limit: int = 8) -> list[dict[str, Any]]: - if not hasattr(self, "db"): - return [] - rows = list( - self.db.exec( - select(Message) - .where( - Message.tenant_id == chat_session.tenant_id, - Message.session_id == chat_session.id, - ) - .order_by(Message.created_at.desc()) - .limit(limit) - ).all() - ) - rows.reverse() - return [self._message_context_entry(row) for row in rows] - def _conversation_context( self, chat_session: ChatSession, @@ -5656,9 +1127,7 @@ def _conversation_context( context = build_conversation_context( [self._message_context_entry(row) for row in rows], context_state=chat_session.context_state_json, - summary_builder=self._context_summary_builder(model_config) - if model_config - else None, + summary_builder=self._context_summary_builder(model_config) if model_config else None, ) next_state = context.get("context_state") if isinstance(next_state, dict) and next_state != (chat_session.context_state_json or {}): @@ -5666,13 +1135,7 @@ def _conversation_context( self.db.add(chat_session) return context - @staticmethod - def _context_compacted_now(context: dict[str, object] | None) -> bool: - return LegacyConversationProjection.context_compacted_now(context) - - def _context_summary_builder( - self, model_config: ModelConfig - ) -> Callable[[str, str, int], str]: + def _context_summary_builder(self, model_config: ModelConfig) -> Callable[[str, str, int], str]: def summarize(label: str, source: str, token_budget: int) -> str: payload = stage_payload( phase="Context Compression", @@ -5685,19 +1148,17 @@ def summarize(label: str, source: str, token_budget: int) -> str: "删除寒暄、重复内容、内部 ID、时间戳和推理过程,不新增原文没有的信息。" ), stage_data={"history_to_compress": source}, - output_contract=( - f"只输出一段纯文本摘要,控制在约 {token_budget} tokens 以内。" - ), + output_contract=(f"只输出一段纯文本摘要,控制在约 {token_budget} tokens 以内。"), ) with llm_operation("context.compact"): - return LLMClient(model_config).generate_text( - unified_system_prompt(), payload - ).strip() + return ( + LLMClient(model_config).generate_text(unified_system_prompt(), payload).strip() + ) return summarize def _message_context_entry(self, row: Message) -> dict[str, Any]: - return LegacyConversationProjection.message_context_entry(row) + return ConversationProjection.message_context_entry(row) def _assistant_message_metadata( self, @@ -5705,12 +1166,12 @@ def _assistant_message_metadata( chat_session: ChatSession, source_message: str | None = None, ) -> dict[str, Any]: - return LegacyConversationProjection.assistant_message_metadata( + return ConversationProjection.assistant_message_metadata( step_result, citation_deduper=self._dedupe_knowledge_citations ) def _dedupe_knowledge_citations(self, citations: list[dict[str, Any]]) -> list[dict[str, Any]]: - return LegacyConversationProjection.dedupe_knowledge_citations(citations) + return ConversationProjection.dedupe_knowledge_citations(citations) def _append_message( self, @@ -5809,141 +1270,7 @@ def _persist_cancelled_assistant_message( return assistant_message def _user_message_metadata(self, request: ChatTurnRequest) -> dict[str, Any]: - return LegacyConversationProjection.user_message_metadata(request) - - def _record_runtime_event( - self, - tenant_id: str, - chat_session: ChatSession, - before_skill: str | None, - before_step: str | None, - decision: RouterDecision, - ) -> None: - event_type = "skill_step_changed" - if decision.decision == "start_new_task": - event_type = "skill_started" - elif decision.decision == "switch_to_pending": - event_type = "skill_resumed" - elif decision.decision == "complete_task": - event_type = "skill_exited" - elif decision.decision == "handoff_human": - event_type = "handoff_triggered" - - if ( - event_type == "skill_step_changed" - and before_skill == chat_session.active_skill_id - and before_step == chat_session.active_step_id - ): - return - - payload = { - "decision": decision.decision, - "from_skill_id": before_skill, - "to_skill_id": chat_session.active_skill_id, - "from_skill_version": self._skill_version(tenant_id, before_skill), - "to_skill_version": self._skill_version(tenant_id, chat_session.active_skill_id), - "from_step_id": before_step, - "to_step_id": chat_session.active_step_id, - } - self.events.record(tenant_id, chat_session.id, event_type, payload) - - def _skill_version(self, tenant_id: str, skill_id: str | None) -> str | None: - if not skill_id: - return None - row = self.db.exec( - select(Skill.version).where(Skill.tenant_id == tenant_id, Skill.skill_id == skill_id) - ).first() - return str(row) if row else None - - def _runtime_stream_context( - self, - decision: RouterDecision, - before_skill: str | None, - before_step: str | None, - chat_session: ChatSession, - ) -> dict[str, object]: - return LegacyConversationProjection.runtime_stream_context( - decision, before_skill, before_step, chat_session - ) - - def _skill_state_payload( - self, - chat_session: ChatSession, - skills: list[Skill], - runtime_context: dict[str, object] | None = None, - *, - user_message_id: str | None = None, - ) -> dict[str, object]: - payload = LegacyConversationProjection.skill_state_payload( - chat_session, skills, runtime_context - ) - return self._turn_payload(payload, user_message_id) - - def _tool_activity_payload( - self, - tenant_id: str, - tool_name: str, - tool_result: ToolResult, - tool_call: ToolCall | None = None, - tool_call_id: str | None = None, - ) -> dict[str, object]: - tool = self.db.exec( - select(Tool).where(Tool.tenant_id == tenant_id, Tool.name == tool_name) - ).first() - payload: dict[str, object] = { - "toolId": tool_name, - "toolName": tool.display_name or tool.name if tool else tool_name, - "rawToolName": tool_name, - "content": tool_result.model_dump(mode="json"), - "isError": not tool_result.success, - "success": tool_result.success, - } - if tool_call: - payload["toolCall"] = tool_call.model_dump(mode="json") - payload["arguments"] = tool_call.arguments - if tool_call_id: - payload["toolCallId"] = tool_call_id - return payload - - def _record_general_skill_run_events( - self, - tenant_id: str, - chat_session: ChatSession, - run_response: GeneralSkillRunResponse, - user_message_id: str | None = None, - include_trace: bool = True, - ) -> None: - if include_trace: - for item in run_response.execution_trace: - self.events.record( - tenant_id, - chat_session.id, - "general_skill_trace", - self._turn_payload( - { - "skill_slug": run_response.skill_slug, - "operation": run_response.operation, - **item, - }, - user_message_id, - ), - ) - self.events.record( - tenant_id, - chat_session.id, - "general_skill_run_finished", - self._turn_payload( - { - "skill_slug": run_response.skill_slug, - "operation": run_response.operation, - "success": bool(run_response.structured_result.get("success", True)), - "stdout_preview": run_response.stdout[:600], - "stderr_preview": run_response.stderr[:600], - "structured_result": run_response.structured_result, - }, - user_message_id, - ), - ) + return ConversationProjection.user_message_metadata(request) def _enqueue_memory_capture( self, @@ -6018,9 +1345,7 @@ def _finalize_turn( metadata = self._assistant_message_metadata(step_result, chat_session, source_message) if assistant_metadata_override: metadata = {**metadata, **dict(assistant_metadata_override)} - reply = restore_truncated_atomic_references( - reply, metadata.get("knowledge_citations") - ) + reply = restore_truncated_atomic_references(reply, metadata.get("knowledge_citations")) reply = self._normalize_reply_citation_labels(reply, metadata.get("knowledge_citations")) reply = self._strip_trailing_citation_summary(reply) reply, compacted_citations = compact_knowledge_citation_labels( @@ -6074,17 +1399,6 @@ def _finalize_turn( ) return reply - def _restore_reply_atomic_references( - self, - reply: str, - step_result: StepAgentResult | None, - ) -> str: - knowledge_results = list(step_result.knowledge_results or []) if step_result else [] - citations = self._dedupe_knowledge_citations( - knowledge_citations_from_results(knowledge_results) - ) - return restore_truncated_atomic_references(reply, citations) - def _mark_session_running(self, chat_session: ChatSession) -> None: if chat_session.status == "handoff": return @@ -6092,12 +1406,11 @@ def _mark_session_running(self, chat_session: ChatSession) -> None: chat_session.updated_at = utc_now() self.db.add(chat_session) - @staticmethod def _fallback_session_title_from_message(message: str) -> str: - return LegacyConversationProjection.fallback_session_title(message) + return ConversationProjection.fallback_session_title(message) def _normalize_reply_citation_labels(self, reply: str, citations: object) -> str: - return LegacyConversationProjection.normalize_reply_citation_labels(reply, citations) + return ConversationProjection.normalize_reply_citation_labels(reply, citations) def _strip_trailing_citation_summary(self, reply: str) -> str: - return LegacyConversationProjection.strip_trailing_citation_summary(reply) + return ConversationProjection.strip_trailing_citation_summary(reply) diff --git a/backend/app/core/legacy_conversation_projection.py b/backend/app/core/conversation_projection.py similarity index 92% rename from backend/app/core/legacy_conversation_projection.py rename to backend/app/core/conversation_projection.py index f5fa7c9d..b14b83a4 100644 --- a/backend/app/core/legacy_conversation_projection.py +++ b/backend/app/core/conversation_projection.py @@ -13,7 +13,7 @@ from app.session.session_schema import ChatTurnRequest, RouterDecision, StepAgentResult -class LegacyConversationProjection: +class ConversationProjection: @staticmethod def message_context_entry(row: Message) -> dict[str, Any]: entry: dict[str, Any] = { @@ -39,16 +39,11 @@ def assistant_message_metadata( cls, step_result: StepAgentResult | None, *, - citation_deduper: Callable[ - [list[dict[str, Any]]], list[dict[str, Any]] - ] - | None = None, + citation_deduper: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] | None = None, ) -> dict[str, Any]: knowledge_results = list(step_result.knowledge_results or []) if step_result else [] dedupe = citation_deduper or cls.dedupe_knowledge_citations - citations = dedupe( - knowledge_citations_from_results(knowledge_results) - ) + citations = dedupe(knowledge_citations_from_results(knowledge_results)) if not citations: return {} latest_query = next( @@ -102,9 +97,7 @@ def user_message_metadata(request: ChatTurnRequest) -> dict[str, Any]: if request.model_config_id: metadata["model_config_id"] = request.model_config_id if request.attachments: - metadata["attachments"] = [ - item.model_dump(mode="json") for item in request.attachments - ] + metadata["attachments"] = [item.model_dump(mode="json") for item in request.attachments] return metadata @staticmethod @@ -148,9 +141,7 @@ def skill_state_payload( for task in chat_session.pending_tasks_json or []: if not isinstance(task, dict): continue - skill_id = str( - task.get("target_skill_id") or task.get("skill_id") or "" - ).strip() + skill_id = str(task.get("target_skill_id") or task.get("skill_id") or "").strip() if not skill_id or skill_id not in visible_skill_ids: continue current_skills.append( diff --git a/backend/app/core/legacy_graph_rules.py b/backend/app/core/graph_rules.py similarity index 94% rename from backend/app/core/legacy_graph_rules.py rename to backend/app/core/graph_rules.py index cd1e2ded..78d0d21b 100644 --- a/backend/app/core/legacy_graph_rules.py +++ b/backend/app/core/graph_rules.py @@ -4,8 +4,8 @@ from typing import Any -class LegacyGraphRules: - """Pure legacy Skill graph rules extracted from AgentLoop.""" +class GraphRules: + """Pure Skill graph rules shared by Harness v2 execution and session projection.""" @staticmethod def node_as_step(node: dict[str, Any]) -> dict[str, Any]: @@ -48,9 +48,7 @@ def ordered_nodes( if not nodes: return [] nodes_by_id = { - str(node.get("node_id") or ""): node - for node in nodes - if node.get("node_id") + str(node.get("node_id") or ""): node for node in nodes if node.get("node_id") } start_node_id = str(content.get("start_node_id") or "").strip() if not start_node_id or start_node_id not in nodes_by_id: @@ -107,10 +105,7 @@ def next_steps_from_parts( nodes: list[dict[str, Any]], outgoing: list[dict[str, Any]], ) -> list[dict[str, Any]]: - nodes_by_id = { - str(node.get("node_id") or ""): cls.node_as_step(node) - for node in nodes - } + nodes_by_id = {str(node.get("node_id") or ""): cls.node_as_step(node) for node in nodes} return [ nodes_by_id[target_id] for target_id in (str(edge.get("next_node_id") or "") for edge in outgoing) @@ -133,10 +128,7 @@ def default_next_step_from_parts( nodes: list[dict[str, Any]], outgoing: list[dict[str, Any]], ) -> dict[str, Any] | None: - nodes_by_id = { - str(node.get("node_id") or ""): cls.node_as_step(node) - for node in nodes - } + nodes_by_id = {str(node.get("node_id") or ""): cls.node_as_step(node) for node in nodes} if not outgoing: return None if len(outgoing) == 1: @@ -265,9 +257,7 @@ def terminal_position( ) -> bool: if not active_step_id: return False - terminal_node_ids = { - str(node_id) for node_id in content.get("terminal_node_ids", []) - } + terminal_node_ids = {str(node_id) for node_id in content.get("terminal_node_ids", [])} if active_step_id not in terminal_node_ids: return False current_step = cls.current_step(content, active_step_id) @@ -308,6 +298,5 @@ def terminal_position_from_step( "ask_clarification", } return all( - action in terminal_actions or action.startswith("call_tool:") - for action in actions + action in terminal_actions or action.startswith("call_tool:") for action in actions ) diff --git a/backend/app/core/legacy_general_skill_action.py b/backend/app/core/legacy_general_skill_action.py deleted file mode 100644 index 7269db4f..00000000 --- a/backend/app/core/legacy_general_skill_action.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Callable -from typing import Any - -from app.db.models import ChatSession, GeneralSkill, ModelConfig -from app.general_skills.schema import GeneralSkillRunResponse, GeneralSkillSelection -from app.llm import LLMError -from app.session.session_schema import ChatTurnRequest -from app.tools.tool_schema import ToolCall, ToolError, ToolResult - - -class LegacyGeneralSkillAction: - def __init__(self, events: Any) -> None: - self.events = events - - def execute_tool_call( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - tool_call: ToolCall, - agent_id: str | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - conversation_context: dict[str, object] | None, - memory_context: list[dict[str, object]] | None, - *, - tool_prefix: str, - list_skills: Callable[[str, str | None], list[GeneralSkill]], - model_resolver: Callable[[ChatTurnRequest, str | None], ModelConfig | None], - precondition_error_type: type[Exception], - validator: Callable[..., ToolResult | None], - runner: Callable[..., GeneralSkillRunResponse], - ) -> ToolResult: - slug = tool_call.name.removeprefix(tool_prefix).strip() - if not slug: - return ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError(code="INVALID_GENERAL_SKILL", message="通用技能名称为空。"), - ) - skill = next( - (item for item in list_skills(request.tenant_id, agent_id) if item.slug == slug), - None, - ) - if not skill: - return ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError( - code="GENERAL_SKILL_NOT_FOUND", message="通用技能不存在或未发布。" - ), - ) - try: - model_config = model_resolver(request, agent_id) - except Exception as exc: - if not isinstance(exc, precondition_error_type): - raise - return ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError( - code=str(getattr(exc, "code", "")).upper(), - message=str(getattr(exc, "message", exc)), - ), - ) - if not model_config: - return ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError(code="MISSING_MODEL_CONFIG", message="没有默认模型配置。"), - ) - query = str(tool_call.arguments.get("query") or request.message).strip() - guard_result = validator( - request, - chat_session, - tool_call, - skill, - query, - model_config, - agent_id, - conversation_context, - memory_context, - ) - if guard_result is not None: - return guard_result - emitted_trace_keys: set[str] = set() - - def emit_trace(trace_item: dict[str, Any]) -> None: - emitted_trace_keys.add(self.trace_key(trace_item)) - payload: dict[str, object] = { - "skill_slug": skill.slug, - "skill_name": skill.name, - "operation": str(tool_call.arguments.get("operation") or "execute"), - **trace_item, - } - self.events.record( - request.tenant_id, chat_session.id, "general_skill_trace", payload - ) - if stream_events is not None: - stream_events.append(("general_skill_trace", payload)) - - try: - response = runner( - skill, - query, - model_config, - request.user_id, - event_sink=emit_trace, - conversation_context=conversation_context, - memory_context=memory_context, - ) - except Exception as exc: # noqa: BLE001 - legacy runner isolation boundary - return ToolResult( - tool_name=tool_call.name, - success=False, - data=None, - error=ToolError(code="GENERAL_SKILL_EXECUTION_ERROR", message=str(exc)), - ) - for trace_item in response.execution_trace: - if self.trace_key(trace_item) not in emitted_trace_keys: - emit_trace(trace_item) - structured = ( - response.structured_result - if isinstance(response.structured_result, dict) - else {} - ) - success = structured.get("success") - is_success = True if success is None else bool(success) - finished_payload: dict[str, object] = { - "skill_slug": response.skill_slug, - "operation": response.operation, - "success": is_success, - "stdout_preview": response.stdout[:600], - "stderr_preview": response.stderr[:600], - "structured_result": response.structured_result, - "tool_call": tool_call.model_dump(mode="json"), - } - self.events.record( - request.tenant_id, - chat_session.id, - "general_skill_run_finished", - finished_payload, - ) - if stream_events is not None: - stream_events.append(("general_skill_run_finished", finished_payload)) - data = { - "skill_slug": response.skill_slug, - "operation": response.operation, - "reply": response.reply, - "structured_result": response.structured_result, - "stdout": response.stdout, - "stderr": response.stderr, - "generated_code": response.generated_code, - "execution_trace": response.execution_trace, - } - if is_success: - return ToolResult(tool_name=tool_call.name, success=True, data=data, error=None) - return ToolResult( - tool_name=tool_call.name, - success=False, - data=data, - error=ToolError( - code=str(structured.get("error") or "GENERAL_SKILL_FAILED"), - message=str( - structured.get("message") - or response.reply - or "通用技能执行失败。" - ), - ), - ) - - def validate_tool_match( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - tool_call: ToolCall, - requested_skill: GeneralSkill, - query: str, - model_config: ModelConfig, - agent_id: str | None, - conversation_context: dict[str, object] | None, - memory_context: list[dict[str, object]] | None, - *, - validated_calls: set[tuple[str, str, str]], - call_key: Callable[[str, str, str], tuple[str, str, str]], - list_skills: Callable[[str, str | None], list[GeneralSkill]], - selector: Callable[..., GeneralSkillSelection], - ) -> ToolResult | None: - key = call_key(chat_session.id, tool_call.name, query) - if key in validated_calls: - validated_calls.discard(key) - return None - if not query: - return ToolResult( - tool_name=tool_call.name, - success=False, - data={ - "requested_slug": requested_skill.slug, - "selected_slug": None, - "reason": "通用技能调用缺少自然语言任务。", - }, - error=ToolError( - code="GENERAL_SKILL_MISMATCH", - message="通用技能调用缺少自然语言任务。", - ), - ) - candidates = list_skills(request.tenant_id, agent_id) - if not candidates: - return ToolResult( - tool_name=tool_call.name, - success=False, - data={ - "requested_slug": requested_skill.slug, - "selected_slug": None, - "reason": "当前员工没有可用通用技能。", - }, - error=ToolError( - code="GENERAL_SKILL_NOT_FOUND", message="当前员工没有可用通用技能。" - ), - ) - try: - selection = selector( - query, - candidates, - model_config, - conversation_context, - memory_context, - ) - except LLMError: - return None - selected_slug = selection.selected_slug if selection.use_general_skill else None - if selected_slug == requested_skill.slug: - return None - payload = { - "requested_slug": requested_skill.slug, - "selected_slug": selected_slug, - "reason": selection.reason, - "query": query, - "tool_call": tool_call.model_dump(mode="json"), - } - self.events.record( - request.tenant_id, chat_session.id, "general_skill_guard_rejected", payload - ) - return ToolResult( - tool_name=tool_call.name, - success=False, - data=payload, - error=ToolError( - code="GENERAL_SKILL_MISMATCH", - message="通用技能与当前子任务不匹配,已取消调用。", - ), - ) - - @staticmethod - def call_key(session_id: str, tool_name: str, query: str) -> tuple[str, str, str]: - return session_id, tool_name.strip(), " ".join(query.split()) - - @staticmethod - def trace_key(trace_item: dict[str, Any]) -> str: - return json.dumps(trace_item, ensure_ascii=False, sort_keys=True, default=str) diff --git a/backend/app/core/legacy_knowledge_action.py b/backend/app/core/legacy_knowledge_action.py deleted file mode 100644 index 48ecf7a1..00000000 --- a/backend/app/core/legacy_knowledge_action.py +++ /dev/null @@ -1,474 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from contextvars import ContextVar -from typing import Any - -from sqlmodel import Session - -from app.db.models import ChatSession, ModelConfig, Skill, Tool -from app.general_skills.schema import GeneralSkillSelection -from app.knowledge.schema import KnowledgeSearchRequest, KnowledgeSearchResponse -from app.session.session_schema import ( - ChatTurnRequest, - KnowledgeQuery, - RouterDecision, - StepAgentResult, -) - -StatusCallback = Callable[[str, dict[str, object]], None] -ScopeIds = Callable[[dict[str, Any], str, str], list[str]] - -_STEPS_SEEN: ContextVar[set[tuple[str, str]] | None] = ContextVar( - "knowledge_steps_seen", default=None -) -_RESULTS_CACHE: ContextVar[dict[tuple[str, str], dict[str, Any]] | None] = ContextVar( - "knowledge_results_cache", default=None -) -KNOWLEDGE_STEPS_SEEN = _STEPS_SEEN -KNOWLEDGE_RESULTS_CACHE = _RESULTS_CACHE - - -class LegacyKnowledgeAction: - def __init__(self, db: Session | None, events: Any = None) -> None: - self.db = db - self.events = events - - @staticmethod - def reset_turn() -> None: - _STEPS_SEEN.set(set()) - _RESULTS_CACHE.set({}) - - def execute_cycle( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - step_result: StepAgentResult, - memory_context: list[dict[str, object]] | None, - conversation_context: dict[str, object] | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - status_callback: StatusCallback | None, - *, - key_resolver: Callable[[ChatSession, Skill | None], tuple[str, str]], - query_claimer: Callable[[ChatSession, Skill | None], bool], - visible_knowledge_base_ids: Callable[[str, str | None], list[str]], - requires_resource_filter: Callable[[str, str | None], bool], - continue_after_query: Callable[..., StepAgentResult], - scope_ids: ScopeIds, - knowledge_service_factory: Callable[[Session], Any], - ) -> StepAgentResult: - query = step_result.knowledge_query - if not query or not query.query.strip(): - return step_result - knowledge_key = key_resolver(chat_session, active_skill) - if not query_claimer(chat_session, active_skill): - cached = (_RESULTS_CACHE.get() or {}).get(knowledge_key) - if cached is None: - return step_result - return continue_after_query( - request, - chat_session, - active_skill, - tools, - model_config, - cached, - memory_context, - conversation_context, - ) - query_scope = dict(query.scope or {}) - runtime_forced = bool(query_scope.pop("_runtime_forced", False)) - payload = { - "phase": "knowledge", - "text": "正在检索知识", - "query": query.model_dump(mode="json"), - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - } - self.events.record(request.tenant_id, chat_session.id, "knowledge_query_started", payload) - if stream_events is not None: - stream_events.append(("status", payload)) - if status_callback is not None: - status_callback("knowledge", payload) - - try: - knowledge_base_ids = visible_knowledge_base_ids( - request.tenant_id, chat_session.agent_id - ) - scoped_ids = scope_ids( - query_scope, "knowledge_base_ids", "knowledge_base_id" - ) - if scoped_ids: - knowledge_base_ids = [ - item for item in knowledge_base_ids if item in scoped_ids - ] - if requires_resource_filter( - request.tenant_id, chat_session.agent_id - ) and not knowledge_base_ids: - search_response = KnowledgeSearchResponse( - selected_buckets=[], - chunks=[], - trace=[], - route_trace=[], - selected_documents=[], - expanded_sections=[], - evidence_pack=[], - ) - else: - search_query = query.query.strip() - original_message = request.message.strip() - if not runtime_forced and original_message and original_message not in search_query: - search_query = f"{search_query}\n{original_message}" - if self.db is None: - raise RuntimeError("Knowledge search requires a database session") - search_response = knowledge_service_factory(self.db).search( - KnowledgeSearchRequest( - tenant_id=request.tenant_id, - agent_id=chat_session.agent_id, - query=search_query, - query_type=query.query_type, - desired_evidence=query.desired_evidence, - scope=query_scope, - mode="chat", - knowledge_base_ids=knowledge_base_ids, - knowledge_base_version_ids=scope_ids( - query_scope, - "knowledge_base_version_ids", - "knowledge_base_version_id", - ), - document_ids=scope_ids( - query_scope, "document_ids", "document_id" - ), - max_chunks=max(1, min(query.max_chunks, 12)), - max_buckets=4, - max_depth=max(1, min(query.max_depth, 4)), - need_evidence_pack=True, - ), - model_config, - ) - except Exception as exc: - failed_payload = { - "phase": "knowledge", - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - "error_type": type(exc).__name__, - } - self.events.record( - request.tenant_id, - chat_session.id, - "knowledge_query_failed", - failed_payload, - ) - if stream_events is not None: - stream_events.append(("knowledge_query_failed", failed_payload)) - raise - knowledge_items = self.response_items( - query, request.message, search_response, runtime_forced=runtime_forced - ) - cache = _RESULTS_CACHE.get() - if cache is None: - cache = {} - _RESULTS_CACHE.set(cache) - cache[knowledge_key] = knowledge_items - self.events.record( - request.tenant_id, - chat_session.id, - "knowledge_query_finished", - knowledge_items, - ) - if stream_events is not None: - for trace in search_response.route_trace or search_response.trace: - stream_events.append(("status", {"phase": "knowledge", **trace})) - stream_events.append(("knowledge_result", knowledge_items)) - return continue_after_query( - request, - chat_session, - active_skill, - tools, - model_config, - knowledge_items, - memory_context, - conversation_context, - ) - - def continue_after_query( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - knowledge_items: dict[str, Any], - memory_context: list[dict[str, object]] | None, - conversation_context: dict[str, object] | None, - *, - step_runner: Callable[..., StepAgentResult], - result_applier: Callable[..., None], - ) -> StepAgentResult: - result = step_runner( - request, - chat_session, - active_skill, - tools, - model_config, - repair_reason="knowledge_continuation", - repair_context={ - "reason": "knowledge_continuation", - "knowledge_results": knowledge_items, - "instruction": "基于知识结果继续判断下一步动作;如果知识足够,推进、调用工具或回复;如果不足,由模型决定是否继续追问或停止。", - }, - memory_context=memory_context, - conversation_context=conversation_context, - current_knowledge=[knowledge_items], - allow_general_skill_selection=False, - ) - result.knowledge_results = [knowledge_items] - result_applier(request.tenant_id, chat_session, result, active_skill) - return result - - def auto_step_result( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - model_config: ModelConfig, - router_decision: RouterDecision, - selection: GeneralSkillSelection | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - status_callback: StatusCallback | None, - *, - items_loader: Callable[..., dict[str, Any] | None], - ) -> StepAgentResult: - del router_decision - if selection is None or not selection.use_knowledge: - return StepAgentResult() - query = KnowledgeQuery( - query=(selection.knowledge_query or request.message).strip(), - reason=selection.reason or "第二轮能力选择判断需要企业知识", - max_chunks=8, - max_depth=3, - ) - payload = { - "phase": "knowledge", - "text": "正在检索业务资料", - "query": query.model_dump(mode="json"), - "auto": True, - } - self.events.record(request.tenant_id, chat_session.id, "knowledge_query_started", payload) - if stream_events is not None: - stream_events.append(("status", payload)) - if status_callback is not None: - status_callback("knowledge", payload) - knowledge_items = items_loader( - request.tenant_id, - chat_session.agent_id, - request.message, - query, - model_config, - ) - finished_payload = knowledge_items or self.empty_items(query, request.message) - self.events.record( - request.tenant_id, - chat_session.id, - "knowledge_query_finished", - {**finished_payload, "auto": True}, - ) - if stream_events is not None: - for trace in finished_payload.get("trace") or []: - stream_events.append(("status", {"phase": "knowledge", **trace})) - stream_events.append(("knowledge_result", finished_payload)) - return StepAgentResult( - knowledge_query=query, - knowledge_results=[knowledge_items] if knowledge_items else [], - ) - - def items_for_message( - self, - tenant_id: str, - agent_id: str, - message: str, - query: KnowledgeQuery | None, - model_config: ModelConfig | None, - *, - visible_knowledge_base_ids: Callable[[str, str | None], list[str]], - requires_resource_filter: Callable[[str, str | None], bool], - scope_ids: ScopeIds, - knowledge_service_factory: Callable[[Session], Any], - ) -> dict[str, Any] | None: - knowledge_base_ids = visible_knowledge_base_ids(tenant_id, agent_id) - query_scope = dict((query.scope if query else {}) or {}) - query_scope.pop("_runtime_forced", None) - scoped_ids = scope_ids(query_scope, "knowledge_base_ids", "knowledge_base_id") - if scoped_ids: - knowledge_base_ids = [item for item in knowledge_base_ids if item in scoped_ids] - if requires_resource_filter(tenant_id, agent_id) and not knowledge_base_ids: - return None - knowledge_query = query or KnowledgeQuery( - query=message, - reason="用户要求基于业务资料或规则回答", - max_chunks=8, - max_depth=3, - ) - if self.db is None: - raise RuntimeError("Knowledge search requires a database session") - response = knowledge_service_factory(self.db).search( - KnowledgeSearchRequest( - tenant_id=tenant_id, - agent_id=agent_id, - query=knowledge_query.query.strip() or message, - query_type=knowledge_query.query_type, - desired_evidence=knowledge_query.desired_evidence, - scope=query_scope, - mode="chat", - knowledge_base_ids=knowledge_base_ids, - knowledge_base_version_ids=scope_ids( - query_scope, - "knowledge_base_version_ids", - "knowledge_base_version_id", - ), - document_ids=scope_ids(query_scope, "document_ids", "document_id"), - max_chunks=8, - max_buckets=4, - max_depth=3, - need_evidence_pack=True, - ), - model_config, - ) - if not ( - response.selected_concepts - or response.okf_citations - or response.evidence_pack - or response.chunks - ): - return None - return self.response_items(knowledge_query, message, response) - - def normalize_required_step( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - step_result: StepAgentResult, - *, - current_step: Callable[[Skill, str | None], dict[str, Any] | None], - slot_satisfied: Callable[[dict[str, Any], str], bool], - ) -> StepAgentResult: - if not active_skill: - return step_result - step = current_step(active_skill, chat_session.active_step_id) - if not step or str(step.get("type") or "") != "knowledge_query": - return step_result - effective_slots = {**(chat_session.slots_json or {}), **(step_result.slot_updates or {})} - missing_fields = [ - str(field) - for field in step.get("expected_user_info", []) - if not slot_satisfied(effective_slots, str(field)) - ] - if missing_fields: - step_result.action = "ask_user" if step_result.reply else "clarify" - step_result.knowledge_query = None - step_result.tool_call = None - step_result.next_step_id = None - step_result.knowledge_results = [] - step_result.is_step_completed = False - step_result.handoff = False - return step_result - if step_result.knowledge_query is None: - scope = dict(step.get("knowledge_scope") or {}) - query_fields = scope.pop("query_fields", []) - query_parts = [ - str(step.get("name") or "").strip(), - str(step.get("instruction") or "").strip(), - ] - if isinstance(query_fields, list): - for field in query_fields: - field_name = str(field).strip() - if field_name and slot_satisfied(effective_slots, field_name): - query_parts.append(f"{field_name}: {effective_slots[field_name]}") - scope["_runtime_forced"] = True - step_result.knowledge_query = KnowledgeQuery( - query="\n".join(part for part in query_parts if part), - reason="当前 SOP 节点要求检索知识", - scope=scope, - query_type="policy_check", - max_chunks=8, - max_depth=3, - ) - self.events.record( - request.tenant_id, - chat_session.id, - "knowledge_query_forced", - { - "skill_id": active_skill.skill_id, - "step_id": chat_session.active_step_id, - "reason": "required_knowledge_node_missing_query", - }, - ) - step_result.action = "query_knowledge" - step_result.reply = None - step_result.tool_call = None - step_result.next_step_id = None - step_result.knowledge_results = [] - step_result.is_step_completed = False - step_result.handoff = False - return step_result - - @staticmethod - def claim_query(chat_session: ChatSession, active_skill: Skill | None) -> bool: - if not active_skill or not chat_session.active_step_id: - return True - seen = _STEPS_SEEN.get() - if seen is None: - seen = set() - _STEPS_SEEN.set(seen) - key = (active_skill.skill_id, chat_session.active_step_id) - if key in seen: - return False - seen.add(key) - return True - - @staticmethod - def step_key(chat_session: ChatSession, active_skill: Skill | None) -> tuple[str, str]: - return ( - active_skill.skill_id if active_skill else "", - str(chat_session.active_step_id or ""), - ) - - @staticmethod - def response_items( - query: KnowledgeQuery, - source_message: str, - response: KnowledgeSearchResponse, - *, - runtime_forced: bool = False, - ) -> dict[str, Any]: - return { - "query": query.model_dump(mode="json"), - "source_message": None if runtime_forced else source_message, - "selected_buckets": [ - item.model_dump(mode="json") for item in response.selected_buckets - ], - "chunks": [item.model_dump(mode="json") for item in response.chunks], - "trace": response.route_trace or response.trace, - "selected_documents": response.selected_documents, - "selected_concepts": response.selected_concepts, - "expanded_sections": response.expanded_sections, - "okf_citations": response.okf_citations, - "evidence_pack": response.evidence_pack, - } - - @staticmethod - def empty_items(query: KnowledgeQuery, source_message: str) -> dict[str, Any]: - return { - "query": query.model_dump(mode="json"), - "source_message": source_message, - "selected_buckets": [], - "chunks": [], - "trace": [], - "selected_documents": [], - "selected_concepts": [], - "expanded_sections": [], - "okf_citations": [], - "evidence_pack": [], - } diff --git a/backend/app/core/legacy_reflection_coordinator.py b/backend/app/core/legacy_reflection_coordinator.py deleted file mode 100644 index 3d87e91f..00000000 --- a/backend/app/core/legacy_reflection_coordinator.py +++ /dev/null @@ -1,560 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - -from app.core.reflection_agent import ReflectionDecision -from app.db.models import ChatSession, ModelConfig, Skill, Tool -from app.session.session_schema import ChatTurnRequest, RouterDecision, StepAgentResult -from app.tools.tool_schema import ToolCall, ToolResult - -EventRecorder = Callable[[str, str, str, dict[str, Any]], None] - - -class LegacyReflectionCoordinator: - @staticmethod - def run_rounds( - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - model_config: ModelConfig, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - max_rounds: int, - conversation_context: dict[str, object] | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - completed_skill_ids_this_turn: set[str] | None, - memory_context: list[dict[str, object]] | None, - *, - round_limit: int, - context_loader: Callable[[ChatSession], dict[str, object]], - should_try: Callable[[RouterDecision, StepAgentResult, ToolResult | None], bool], - reflect_and_retry: Callable[..., tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None, bool]], - record_event: EventRecorder | None, - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - if conversation_context is None: - conversation_context = context_loader(chat_session) - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - rounds = max(0, min(max_rounds, round_limit)) - if rounds <= 0: - if should_try(router_decision, step_result, tool_result): - payload = { - "needs_retry": False, - "reason": "企业端反思轮数配置为 0,已跳过反思。", - "target_skill_id": None, - "target_step_id": None, - "target_tool_name": None, - "skipped": True, - "skip_reason": "reflection_disabled", - } - if record_event is not None: - record_event( - request.tenant_id, - chat_session.id, - "reflection_skipped", - payload, - ) - if stream_events is not None: - stream_events.append(("reflection_decision", payload)) - return active_skill, router_decision, step_result, tool_result - - for round_index in range(rounds): - if not should_try(router_decision, step_result, tool_result): - break - if stream_events is not None and round_index > 0: - stream_events.append( - ( - "status", - { - "phase": "reflecting", - "text": "正在反思", - "reflection_round": round_index + 1, - "reflection_max_rounds": rounds, - }, - ) - ) - ( - active_skill, - router_decision, - step_result, - tool_result, - retried, - ) = reflect_and_retry( - request, - chat_session, - skills, - tools, - model_config, - active_skill, - router_decision, - step_result, - tool_result, - conversation_context, - stream_events, - completed_skill_ids_this_turn, - memory_context, - ) - if not retried: - break - return active_skill, router_decision, step_result, tool_result - - @staticmethod - def reflect_and_retry( - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - model_config: ModelConfig, - active_skill: Skill | None, - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - conversation_context: dict[str, object] | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - completed_skill_ids_this_turn: set[str] | None, - memory_context: list[dict[str, object]] | None, - *, - context_loader: Callable[[ChatSession], dict[str, object]], - should_try: Callable[[RouterDecision, StepAgentResult, ToolResult | None], bool], - review: Callable[..., ReflectionDecision], - llm_error_type: type[Exception], - record_event: EventRecorder, - tool_call_from_reflection: Callable[..., ToolCall | None], - tool_retry_targets_current_skill: Callable[[ReflectionDecision, ChatSession], bool], - retry_with_tool_call: Callable[..., tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]], - router_decision_from_reflection: Callable[..., RouterDecision | None], - retry_with_router_decision: Callable[..., tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]], - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None, bool]: - if conversation_context is None: - conversation_context = context_loader(chat_session) - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - if not should_try(router_decision, step_result, tool_result): - return active_skill, router_decision, step_result, tool_result, False - - try: - reflection = review( - request.message, - chat_session, - active_skill, - router_decision, - step_result, - tool_result, - skills, - tools, - model_config, - conversation_context, - memory_context, - ) - except Exception as exc: - if not isinstance(exc, llm_error_type): - raise - record_event( - request.tenant_id, - chat_session.id, - "reflection_error", - {"message": str(exc)}, - ) - if stream_events is not None: - stream_events.append( - ( - "reflection_decision", - { - "needs_retry": False, - "reason": f"反思失败:{exc}", - "target_skill_id": None, - "target_step_id": None, - "target_tool_name": None, - }, - ) - ) - return active_skill, router_decision, step_result, tool_result, False - - record_event( - request.tenant_id, - chat_session.id, - "reflection_decision_created", - reflection.model_dump(), - ) - if stream_events is not None: - stream_events.append( - ("reflection_decision", reflection.model_dump(mode="json")) - ) - if not reflection.needs_retry: - return active_skill, router_decision, step_result, tool_result, False - - retry_tool_call = tool_call_from_reflection( - reflection, - chat_session, - tools, - request.message, - ) - if retry_tool_call and tool_retry_targets_current_skill( - reflection, chat_session - ): - retry_result = retry_with_tool_call( - request, - chat_session, - active_skill, - router_decision, - retry_tool_call, - reflection.reason, - stream_events, - tools, - model_config, - conversation_context, - memory_context, - ) - return (*retry_result, True) - - retry_router_decision = router_decision_from_reflection( - reflection, - chat_session, - skills, - router_decision, - completed_skill_ids_this_turn, - ) - if retry_router_decision: - retry_result = retry_with_router_decision( - request, - chat_session, - skills, - tools, - retry_router_decision, - model_config, - conversation_context, - stream_events, - memory_context, - ) - return (*retry_result, True) - - if retry_tool_call: - retry_result = retry_with_tool_call( - request, - chat_session, - active_skill, - router_decision, - retry_tool_call, - reflection.reason, - stream_events, - tools, - model_config, - conversation_context, - memory_context, - ) - return (*retry_result, True) - - record_event( - request.tenant_id, - chat_session.id, - "reflection_retry_skipped", - { - "reason": reflection.reason, - "target_skill_id": reflection.target_skill_id, - "target_tool_name": reflection.target_tool_name, - }, - ) - return active_skill, router_decision, step_result, tool_result, False - - @staticmethod - def retry_with_tool_call( - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - router_decision: RouterDecision, - retry_tool_call: ToolCall, - retry_reason: str | None, - stream_events: list[tuple[str, dict[str, object]]] | None, - tools: list[Tool] | None, - model_config: ModelConfig | None, - conversation_context: dict[str, object] | None, - memory_context: list[dict[str, object]] | None, - *, - record_event: EventRecorder, - execute_tool_cycle: Callable[..., tuple[StepAgentResult, ToolResult | None]], - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - retry_step_result = StepAgentResult( - tool_call=retry_tool_call, - next_step_id=chat_session.active_step_id, - is_step_completed=True, - ) - record_event( - request.tenant_id, - chat_session.id, - "reflection_retry_started", - { - "mode": "tool", - "reason": retry_reason, - "target_tool_name": retry_tool_call.name, - }, - ) - retry_step_result, retry_tool_result = execute_tool_cycle( - request, - chat_session, - active_skill, - tools or [], - model_config, - retry_step_result, - stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - return active_skill, router_decision, retry_step_result, retry_tool_result - - @staticmethod - def retry_with_router_decision( - request: ChatTurnRequest, - chat_session: ChatSession, - skills: list[Skill], - tools: list[Tool], - router_decision: RouterDecision, - model_config: ModelConfig, - conversation_context: dict[str, object], - stream_events: list[tuple[str, dict[str, object]]] | None, - memory_context: list[dict[str, object]] | None, - *, - record_event: EventRecorder, - apply_runtime_decision: Callable[[ChatSession, RouterDecision], None], - drop_unavailable_state: Callable[[str, ChatSession, list[Skill]], bool], - should_record_runtime_event: Callable[[RouterDecision, ChatSession, list[Skill], bool], bool], - record_runtime_event: Callable[..., None], - commit: Callable[[], None], - refresh: Callable[[ChatSession], None], - get_active_skill: Callable[..., Skill | None], - skill_state_payload: Callable[..., dict[str, object]], - runtime_stream_context: Callable[..., dict[str, object]], - run_step_with_context_repair: Callable[..., StepAgentResult], - execute_knowledge_cycle: Callable[..., StepAgentResult], - execute_tool_cycle: Callable[..., tuple[StepAgentResult, ToolResult | None]], - ) -> tuple[Skill | None, RouterDecision, StepAgentResult, ToolResult | None]: - record_event( - request.tenant_id, - chat_session.id, - "reflection_retry_started", - { - "mode": "skill", - "target_skill_id": router_decision.target_skill_id, - "target_step_id": router_decision.target_step_id, - "reason": router_decision.reason, - }, - ) - record_event( - request.tenant_id, - chat_session.id, - "router_decision_created", - router_decision.model_dump(), - ) - - before_skill = chat_session.active_skill_id - before_step = chat_session.active_step_id - apply_runtime_decision(chat_session, router_decision) - state_pruned = drop_unavailable_state( - request.tenant_id, chat_session, skills - ) - if should_record_runtime_event( - router_decision, chat_session, skills, state_pruned - ): - record_runtime_event( - request.tenant_id, - chat_session, - before_skill, - before_step, - router_decision, - ) - commit() - refresh(chat_session) - - active_skill = get_active_skill( - request.tenant_id, - chat_session.active_skill_id, - chat_session.agent_id, - ) - if stream_events is not None: - stream_events.append( - ( - "skill_state", - skill_state_payload( - chat_session, - skills, - runtime_stream_context( - router_decision, - before_skill, - before_step, - chat_session, - ), - ), - ) - ) - stream_events.append( - ( - "status", - { - "phase": "stepping", - "text": "正在思考", - "active_skill_id": chat_session.active_skill_id, - "active_step_id": chat_session.active_step_id, - }, - ) - ) - - step_result = run_step_with_context_repair( - request, - chat_session, - active_skill, - tools, - model_config, - router_decision, - memory_context=memory_context, - conversation_context=conversation_context, - stream_events=stream_events, - ) - commit() - refresh(chat_session) - - tool_result: ToolResult | None = None - if step_result.knowledge_query: - step_result = execute_knowledge_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - memory_context, - conversation_context, - stream_events, - ) - commit() - refresh(chat_session) - if step_result.tool_call: - step_result, tool_result = execute_tool_cycle( - request, - chat_session, - active_skill, - tools, - model_config, - step_result, - stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - return active_skill, router_decision, step_result, tool_result - - -class LegacyReflectionPolicy: - @staticmethod - def retry_targets_current_skill( - reflection: ReflectionDecision, chat_session: ChatSession - ) -> bool: - return bool( - reflection.target_tool_name - and ( - not reflection.target_skill_id - or reflection.target_skill_id == chat_session.active_skill_id - ) - ) - - @staticmethod - def router_decision( - reflection: ReflectionDecision, - chat_session: ChatSession, - skills: list[Skill], - previous_decision: RouterDecision, - completed_skill_ids_this_turn: set[str] | None, - *, - record_event: EventRecorder, - first_step_id: Callable[[Skill], str | None], - ) -> RouterDecision | None: - if not reflection.target_skill_id: - return None - completed_skill_ids_this_turn = completed_skill_ids_this_turn or set() - if ( - reflection.target_skill_id in completed_skill_ids_this_turn - and chat_session.active_skill_id != reflection.target_skill_id - ): - record_event( - chat_session.tenant_id, - chat_session.id, - "reflection_retry_skipped_completed_task", - { - "reason": reflection.reason, - "target_skill_id": reflection.target_skill_id, - "active_skill_id": chat_session.active_skill_id, - }, - ) - return None - target_skill = next( - (skill for skill in skills if skill.skill_id == reflection.target_skill_id), - None, - ) - if not target_skill: - return None - decision = ( - "continue_active" - if chat_session.active_skill_id == target_skill.skill_id - else "start_new_task" - ) - return RouterDecision( - decision=decision, - target_skill_id=target_skill.skill_id, - target_step_id=reflection.target_step_id or first_step_id(target_skill), - confidence=0.7, - user_intent=previous_decision.user_intent, - reason=f"反思重试:{reflection.reason or '当前技能或工具可能不匹配用户诉求'}", - ) - - @staticmethod - def tool_call( - reflection: ReflectionDecision, - chat_session: ChatSession, - tools: list[Tool], - user_message: str | None, - *, - general_skill_tool_prefix: str, - build_arguments: Callable[[Tool, dict[str, Any]], dict[str, Any]], - slot_has_value: Callable[[dict[str, Any], str], bool], - ) -> ToolCall | None: - if not reflection.target_tool_name: - return None - tool = next( - ( - item - for item in tools - if item.enabled and item.name == reflection.target_tool_name - ), - None, - ) - if not tool: - return None - if str(getattr(tool, "name", "") or "").startswith( - general_skill_tool_prefix - ): - query = str(user_message or "").strip() - if not query: - return None - return ToolCall(name=tool.name, arguments={"query": query}) - if ( - chat_session.active_skill_id - and tool.allowed_skills_json - and chat_session.active_skill_id not in tool.allowed_skills_json - ): - return None - arguments = build_arguments(tool, chat_session.slots_json or {}) - required = [ - str(field) for field in (tool.input_schema or {}).get("required", []) - ] - if any(not slot_has_value(arguments, field) for field in required): - return None - return ToolCall(name=tool.name, arguments=arguments) - - @staticmethod - def should_try( - router_decision: RouterDecision, - step_result: StepAgentResult, - tool_result: ToolResult | None, - *, - predicate: Callable[[RouterDecision, StepAgentResult, ToolResult | None], bool], - ) -> bool: - return predicate(router_decision, step_result, tool_result) diff --git a/backend/app/core/legacy_tool_action.py b/backend/app/core/legacy_tool_action.py deleted file mode 100644 index 1854205b..00000000 --- a/backend/app/core/legacy_tool_action.py +++ /dev/null @@ -1,250 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from sqlmodel import Session - -from app.db.models import ChatSession, ModelConfig, Skill, Tool -from app.session.session_schema import ChatTurnRequest, StepAgentResult -from app.tools.tool_schema import ToolCall, ToolResult - -StatusCallback = Callable[[str, dict[str, object]], None] - - -@dataclass(frozen=True) -class LegacyToolActionCallbacks: - max_actions: Callable[[str], int] - decision_payload: Callable[..., dict[str, object]] - new_id: Callable[[str], str] - is_general_skill_tool: Callable[[str], bool] - call_signature: Callable[[ToolCall], str] - emit_tool_status: Callable[..., None] - execute_tool_call: Callable[..., ToolResult] - record_result: Callable[[ChatSession, ToolCall, ToolResult], None] - activity_payload: Callable[..., dict[str, object]] - emit_thinking_status: Callable[..., None] - run_step: Callable[..., StepAgentResult] - continuation_context: Callable[..., dict[str, object]] - apply_result: Callable[..., None] - advance_after_tool: Callable[..., None] - - -class LegacyToolAction: - def __init__(self, db: Session, events: Any) -> None: - self.db = db - self.events = events - - def execute_cycle( - self, - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig | None, - step_result: StepAgentResult, - stream_events: list[tuple[str, dict[str, object]]] | None, - status_callback: StatusCallback | None, - conversation_context: dict[str, object] | None, - memory_context: list[dict[str, object]] | None, - callbacks: LegacyToolActionCallbacks, - ) -> tuple[StepAgentResult, ToolResult | None]: - tool_result: ToolResult | None = None - current_knowledge = list(step_result.knowledge_results or []) - seen_calls: set[str] = set() - max_actions = callbacks.max_actions(request.tenant_id) - for iteration in range(max_actions): - tool_call = step_result.tool_call - if not tool_call: - break - tool_call_id = callbacks.new_id("toolcall") - signature = callbacks.call_signature(tool_call) - if signature in seen_calls: - if tool_result and tool_result.success and step_result.reply: - step_result = step_result.model_copy( - update={"tool_call": None, "is_step_completed": True} - ) - payload = callbacks.decision_payload( - iteration + 1, "respond_after_duplicate" - ) - self.events.record( - request.tenant_id, chat_session.id, "agent_loop_completed", payload - ) - if stream_events is not None: - stream_events.append(("agent_loop_completed", payload)) - break - self.events.record( - request.tenant_id, - chat_session.id, - "agent_loop_stopped", - {"reason": "duplicate_tool_call", "tool_call": tool_call.model_dump()}, - ) - break - seen_calls.add(signature) - callbacks.emit_tool_status( - tool_call, tool_call_id, stream_events, status_callback - ) - tool_result = callbacks.execute_tool_call( - request, - chat_session, - tool_call, - tool_call_id, - stream_events=stream_events, - conversation_context=conversation_context, - memory_context=memory_context, - ) - is_read_only_general_skill = ( - callbacks.is_general_skill_tool(tool_call.name) - and str(tool_call.arguments.get("operation") or "execute").strip().lower() - == "read" - ) - if not is_read_only_general_skill: - callbacks.record_result(chat_session, tool_call, tool_result) - if stream_events is not None: - stream_events.append( - ( - "tool_result", - callbacks.activity_payload( - request.tenant_id, - tool_call.name, - tool_result, - tool_call, - tool_call_id, - ), - ) - ) - self.db.commit() - self.db.refresh(chat_session) - if is_read_only_general_skill: - data = tool_result.data if isinstance(tool_result.data, dict) else {} - reply = str(data.get("reply") or "").strip() - if not reply and tool_result.error is not None: - reply = tool_result.error.message - step_result = StepAgentResult( - reply=reply or "已完成 Skill 内容读取。", - knowledge_results=current_knowledge, - is_step_completed=False, - ) - payload = callbacks.decision_payload( - iteration + 1, "respond_after_read" - ) - self.events.record( - request.tenant_id, - chat_session.id, - "agent_loop_completed", - payload, - ) - if stream_events is not None: - stream_events.append(("agent_loop_completed", payload)) - break - if not tool_result.success: - if ( - model_config - and callbacks.is_general_skill_tool(tool_call.name) - and active_skill is not None - ): - callbacks.emit_thinking_status( - chat_session, iteration + 1, stream_events, status_callback - ) - continuation_result = callbacks.run_step( - request, - chat_session, - active_skill, - tools, - model_config, - repair_reason="tool_continuation", - repair_context=callbacks.continuation_context( - request.tenant_id, - tool_call, - tool_result, - chat_session, - iteration + 1, - ), - memory_context=memory_context, - conversation_context=conversation_context, - current_knowledge=current_knowledge, - allow_general_skill_selection=False, - ) - callbacks.apply_result( - request.tenant_id, - chat_session, - continuation_result, - active_skill, - ) - self.db.commit() - self.db.refresh(chat_session) - step_result = continuation_result - break - if not model_config: - callbacks.advance_after_tool( - request.tenant_id, - chat_session, - active_skill, - step_result, - tool_result, - ) - self.db.commit() - self.db.refresh(chat_session) - break - callbacks.emit_thinking_status( - chat_session, iteration + 1, stream_events, status_callback - ) - continuation_result = callbacks.run_step( - request, - chat_session, - active_skill, - tools, - model_config, - repair_reason="tool_continuation", - repair_context=callbacks.continuation_context( - request.tenant_id, - tool_call, - tool_result, - chat_session, - iteration + 1, - ), - memory_context=memory_context, - conversation_context=conversation_context, - current_knowledge=current_knowledge, - allow_general_skill_selection=False, - ) - if current_knowledge and not continuation_result.knowledge_results: - continuation_result.knowledge_results = current_knowledge - callbacks.apply_result( - request.tenant_id, chat_session, continuation_result, active_skill - ) - self.db.commit() - self.db.refresh(chat_session) - step_result = continuation_result - if step_result.tool_call: - payload = callbacks.decision_payload( - iteration + 1, "model_tool_call", step_result.tool_call - ) - self.events.record( - request.tenant_id, chat_session.id, "agent_loop_continued", payload - ) - if stream_events is not None: - stream_events.append(("agent_loop_continued", payload)) - continue - payload = callbacks.decision_payload(iteration + 1, "respond") - self.events.record( - request.tenant_id, chat_session.id, "agent_loop_completed", payload - ) - if stream_events is not None: - stream_events.append(("agent_loop_completed", payload)) - callbacks.advance_after_tool( - request.tenant_id, - chat_session, - active_skill, - StepAgentResult( - tool_call=tool_call, - next_step_id=step_result.next_step_id, - is_step_completed=True, - ), - tool_result, - ) - self.db.commit() - self.db.refresh(chat_session) - break - return step_result, tool_result diff --git a/backend/app/core/legacy_turn_finalizer.py b/backend/app/core/turn_finalizer.py similarity index 95% rename from backend/app/core/legacy_turn_finalizer.py rename to backend/app/core/turn_finalizer.py index a08f6da1..dc094fa4 100644 --- a/backend/app/core/legacy_turn_finalizer.py +++ b/backend/app/core/turn_finalizer.py @@ -10,8 +10,8 @@ ExecutionFinalizeState = Literal["handoff", "completed", "continued"] -class LegacyTurnFinalizer: - """Preserves the legacy post-reply terminal decision and event ordering.""" +class TurnFinalizer: + """Applies the Harness v2 post-reply terminal decision and event ordering.""" @staticmethod def finalize( diff --git a/backend/tests/agent_golden/__init__.py b/backend/tests/agent_golden/__init__.py deleted file mode 100644 index bce3ba9f..00000000 --- a/backend/tests/agent_golden/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Golden characterization support for the Agent runtime migration.""" diff --git a/backend/tests/agent_golden/capture_legacy_fixtures.py b/backend/tests/agent_golden/capture_legacy_fixtures.py deleted file mode 100644 index 7b5faa04..00000000 --- a/backend/tests/agent_golden/capture_legacy_fixtures.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import argparse -from pathlib import Path -from tempfile import TemporaryDirectory - -from _pytest.monkeypatch import MonkeyPatch - -from agent_golden.contract_validation import expected_fixture_matrix -from agent_golden.fixture_writer import capture_legacy_envelopes, write_envelopes -from agent_golden.harness import GoldenHarness -from agent_golden.legacy_scenario_capture import plan_for_variant -from agent_golden.support import load_json - -CAPTURED_VARIANTS = ( - "GT01-sync", - "GT01-sse", - "GT01-feedback-refresh-toggle", - "GT02-ask-refresh-continue", - "GT03-true", - "GT03-false", - "GT04-merge", - "GT13-llm-error", - "GT15-full", - "GT16-history", -) - - -def main(argv: list[str] | None = None) -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--update", - action="store_true", - help="Replace checked-in fixtures. Without this flag, only check for drift.", - ) - parser.add_argument( - "--variant", - choices=CAPTURED_VARIANTS, - action="append", - help="Capture only the selected variant. May be repeated.", - ) - args = parser.parse_args(argv) - repo_root = Path(__file__).resolve().parents[3] - contract_root = repo_root / "contracts" / "agent" / "v1" - matrix = expected_fixture_matrix(contract_root) - with TemporaryDirectory() as directory: - temp_root = Path(directory) - for variant_id in args.variant or CAPTURED_VARIANTS: - monkeypatch = MonkeyPatch() - try: - paths = { - item.plane: item.path - for item in matrix.values() - if item.fixture_set == "legacy_characterization" - and item.variant_id == variant_id - } - captured_revision = None - if not args.update and all(path.is_file() for path in paths.values()): - revisions = { - load_json(path)["source"]["repo_revision"] for path in paths.values() - } - if len(revisions) != 1: - raise AssertionError( - f"fixture provenance revisions disagree for {variant_id}" - ) - captured_revision = revisions.pop() - harness = GoldenHarness( - temp_root / f"{variant_id}.sqlite3", - monkeypatch, - plan_for_variant(variant_id), - ) - try: - envelopes = capture_legacy_envelopes( - contract_root, - harness, - variant_id, - monkeypatch, - captured_revision=captured_revision, - ) - finally: - harness.close() - if args.update: - write_envelopes(envelopes, paths) - continue - mismatches = [ - path - for plane, path in paths.items() - if not path.is_file() or load_json(path) != envelopes[plane] - ] - if mismatches: - relative = [path.relative_to(repo_root).as_posix() for path in mismatches] - raise AssertionError( - "legacy fixture drift detected; inspect the behavior change before " - f"running --update: {relative}" - ) - finally: - monkeypatch.undo() - - -if __name__ == "__main__": - main() diff --git a/backend/tests/agent_golden/conftest.py b/backend/tests/agent_golden/conftest.py deleted file mode 100644 index bba6e7f9..00000000 --- a/backend/tests/agent_golden/conftest.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest - -from agent_golden.harness import GoldenHarness -from agent_golden.scripted_dependencies import ScriptedLLMPlan - - -@pytest.fixture -def golden_harness(tmp_path, monkeypatch) -> Iterator[GoldenHarness]: - harness = GoldenHarness(tmp_path / "golden.sqlite3", monkeypatch, ScriptedLLMPlan()) - try: - yield harness - finally: - harness.close() diff --git a/backend/tests/agent_golden/contract_validation.py b/backend/tests/agent_golden/contract_validation.py deleted file mode 100644 index ea0be3ba..00000000 --- a/backend/tests/agent_golden/contract_validation.py +++ /dev/null @@ -1,664 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator, FormatChecker -from referencing import Registry, Resource - -from agent_golden.support import ( - assert_contiguous_order, - assert_monotonic_timestamps, - load_json, - resolve_json_pointer, -) - -FIXTURE_SETS = { - "legacy_characterization": "legacy", - "contract_v1": "contract_v1", -} -PLANES = ("provider", "domain", "sse", "db_events", "conversation") -TERMINAL_SSE_EVENTS = {"complete", "stream_cancelled", "stream_interrupted"} - - -@dataclass(frozen=True) -class ExpectedFixture: - path: Path - fixture_set: str - scenario_id: str - variant_id: str - plane: str - applicability: str - - -def schema_registry( - contract_root: Path, - manifest: dict[str, Any], -) -> tuple[dict[str, dict[str, Any]], Registry]: - schemas = {item["id"]: load_json(contract_root / item["path"]) for item in manifest["schemas"]} - registry = Registry().with_resources( - (schema_id, Resource.from_contents(schema)) for schema_id, schema in schemas.items() - ) - return schemas, registry - - -def expected_fixture_matrix(contract_root: Path) -> dict[Path, ExpectedFixture]: - catalog = load_json(contract_root / "scenario-catalog.json") - expected: dict[Path, ExpectedFixture] = {} - for scenario in catalog["scenarios"]: - for variant in scenario["variants"]: - for fixture_set, catalog_key in FIXTURE_SETS.items(): - fixture_root = catalog["fixture_roots"][catalog_key] - for plane in PLANES: - relative = catalog["fixture_path_template"].format( - fixture_root=fixture_root, - scenario_id=scenario["id"], - fixture_key=variant["fixture_key"], - plane=plane, - ) - path = contract_root / relative - assert path not in expected, f"duplicate fixture path: {path}" - expected[path] = ExpectedFixture( - path=path, - fixture_set=fixture_set, - scenario_id=scenario["id"], - variant_id=variant["variant_id"], - plane=plane, - applicability=variant["planes"][plane][catalog_key], - ) - return expected - - -def validate_fixture( - contract_root: Path, - expected: ExpectedFixture, - schemas: dict[str, dict[str, Any]], - registry: Registry, -) -> dict[str, Any]: - envelope = load_json(expected.path) - _validate( - envelope, - schemas["fixture-envelope.schema.json"], - registry, - expected.path, - ) - assert envelope["fixture_set"] == expected.fixture_set - assert envelope["scenario_id"] == expected.scenario_id - assert envelope["variant_id"] == expected.variant_id - assert envelope["plane"] == expected.plane - assert envelope["applicability"] == expected.applicability - - if expected.applicability == "required": - payload_schema_id = envelope["payload_schema"] - assert payload_schema_id in schemas, f"unknown payload schema: {payload_schema_id}" - _validate(envelope["payload"], schemas[payload_schema_id], registry, expected.path) - assert envelope["content_hash"] == _payload_hash(envelope["payload"]) - _assert_plane_semantics(expected, envelope["payload"]) - else: - assert envelope["payload_schema"] is None - assert envelope["content_hash"] is None - assert "payload" not in envelope - - observed_document = envelope.get("payload") - for field, status in envelope["legacy_field_observation"].items(): - actual = "present" if _contains_key(observed_document, field) else "absent" - assert status == actual, f"legacy field observation mismatch for {field!r}" - - source = envelope["source"] - source_paths = [ - contract_root.parents[2] / item["path"] for item in source["artifacts"] - ] - assert all(path.is_file() for path in source_paths), source_paths - for artifact, path in zip(source["artifacts"], source_paths, strict=True): - assert artifact["sha256"] == f"sha256:{_file_hash(path)}", artifact["path"] - return envelope - - -def validate_fixture_ownership( - envelope: dict[str, Any], ownership: dict[str, Any] -) -> None: - plane = envelope["plane"] - rules = ownership["planes"][plane] - if envelope["applicability"] == "required": - payload = envelope["payload"] - for owned in rules["owns"]: - missing_paths = [ - path - for path in owned["payload_paths"] - if not _resolve_pointer_pattern(payload, path) - ] - if missing_paths: - raise AssertionError( - f"owned claim {plane}.{owned['claim']} has unresolved payload paths: " - f"{missing_paths}" - ) - forbidden_hits = _find_forbidden_keys(payload, set(rules["forbidden"])) - if forbidden_hits: - raise AssertionError( - f"forbidden fields found in {plane} payload: {forbidden_hits}" - ) - - if envelope["fixture_set"] == "legacy_characterization": - expectations = ownership["legacy_field_observation"]["expectations"] - expected = { - field: rule["overrides"].get(plane, rule["default"]) - for field, rule in expectations.items() - } - if envelope["legacy_field_observation"] != expected: - raise AssertionError( - f"legacy field observations differ from manifest for {plane}: " - f"{envelope['legacy_field_observation']!r} != {expected!r}" - ) - - -def validate_fixture_relationships( - envelopes: dict[str, dict[str, Any]], - ownership: dict[str, Any], - requirements: dict[str, Any] | None = None, -) -> None: - if set(envelopes) != set(PLANES): - raise AssertionError(f"fixture relationship group must contain all planes: {sorted(envelopes)}") - identities = { - (item["fixture_set"], item["scenario_id"], item["variant_id"]) - for item in envelopes.values() - } - if len(identities) != 1: - raise AssertionError(f"fixture relationship identity mismatch: {sorted(identities)}") - fixture_set, _scenario_id, _variant_id = next(iter(identities)) - documents = { - plane: envelope["payload"] - for plane, envelope in envelopes.items() - if envelope["applicability"] == "required" - } - join_rules = _rules_by_id(ownership["join_rules"], "join") - order_rules = _rules_by_id(ownership["happens_before_rules"], "happens-before") - seen: set[tuple[str, str]] = set() - - for envelope in envelopes.values(): - for relation in envelope["joins"]: - identity = ("join", relation["name"]) - if identity in seen: - raise AssertionError(f"duplicate fixture relationship name: {relation['name']!r}") - seen.add(identity) - rule = _relationship_rule(join_rules, relation["rule_id"], fixture_set) - references = relation["references"] - reference_keys = [ - (reference["plane"], reference["pointer"]) for reference in references - ] - if len(reference_keys) != len(set(reference_keys)): - raise AssertionError( - f"join {relation['name']!r} contains duplicate references" - ) - roles = [reference["role"] for reference in references] - if len(roles) != len(set(roles)): - raise AssertionError( - f"join {relation['name']!r} contains duplicate reference roles" - ) - if len(references) < rule["minimum_references"]: - raise AssertionError( - f"join {relation['name']!r} requires {rule['minimum_references']} references" - ) - values = [ - _resolve_scoped_reference(documents, reference, rule["allowed_references"]) - for reference in references - ] - if any(value != values[0] for value in values[1:]): - raise AssertionError(f"join {relation['name']!r} mismatch: {values!r}") - - for relation in envelope["happens_before"]: - identity = ("happens_before", relation["name"]) - if identity in seen: - raise AssertionError(f"duplicate fixture relationship name: {relation['name']!r}") - seen.add(identity) - rule = _relationship_rule(order_rules, relation["rule_id"], fixture_set) - order_type = relation["order_type"] - if order_type not in rule["order_types"]: - raise AssertionError( - f"happens-before {relation['name']!r} disallows order type {order_type!r}" - ) - before = _resolve_scoped_reference( - documents, relation["before"], rule["before_references"] - ) - after = _resolve_scoped_reference( - documents, relation["after"], rule["after_references"] - ) - if relation["before"] == relation["after"]: - raise AssertionError( - f"happens-before {relation['name']!r} self-references one value" - ) - if not _ordered_value(before, order_type) < _ordered_value(after, order_type): - raise AssertionError( - f"happens-before {relation['name']!r} violated: {before!r} !< {after!r}" - ) - - if requirements is not None: - _assert_required_relationships(envelopes, requirements) - - -def _assert_required_relationships( - envelopes: dict[str, dict[str, Any]], - requirements: dict[str, Any], -) -> None: - fixture_set = next(iter(envelopes.values()))["fixture_set"] - variant_id = next(iter(envelopes.values()))["variant_id"] - documents = { - plane: envelope["payload"] - for plane, envelope in envelopes.items() - if envelope["applicability"] == "required" - } - matches = [ - item - for item in requirements["variants"] - if item["fixture_set"] == fixture_set and item["variant_id"] == variant_id - ] - if not matches: - raise AssertionError( - f"missing relationship requirements for {fixture_set}/{variant_id}" - ) - if len(matches) != 1: - raise AssertionError( - f"duplicate relationship requirements for {fixture_set}/{variant_id}" - ) - profiles = { - item["id"]: item for item in requirements["reference_profiles"] - } - if len(profiles) != len(requirements["reference_profiles"]): - raise AssertionError("duplicate relationship reference profile id") - actual = { - ("join" if kind == "join" else "happens_before", plane, relation["name"]): relation - for plane, envelope in envelopes.items() - for kind, collection in ( - ("join", envelope["joins"]), - ("happens_before", envelope["happens_before"]), - ) - for relation in collection - } - required = { - (item["kind"], item["plane"], item["name"]): item - for item in matches[0]["relations"] - } - if len(required) != len(matches[0]["relations"]): - raise AssertionError( - f"duplicate relationship requirements for {fixture_set}/{variant_id}" - ) - missing = set(required) - set(actual) - if missing: - raise AssertionError(f"missing required fixture relationships: {sorted(missing)}") - for identity, requirement in required.items(): - profile = profiles.get(requirement["profile_id"]) - if profile is None: - raise AssertionError( - f"unknown relationship reference profile: {requirement['profile_id']!r}" - ) - if profile["kind"] != requirement["kind"]: - raise AssertionError( - f"relationship profile kind mismatch for {identity}: {profile['kind']!r}" - ) - relation = actual[identity] - if relation["rule_id"] != profile["rule_id"]: - raise AssertionError( - f"relationship rule mismatch for {identity}: " - f"{relation['rule_id']!r} != {profile['rule_id']!r}" - ) - if requirement["kind"] == "join": - _assert_join_profile(identity, relation, profile, documents) - else: - _assert_order_profile(identity, relation, profile, documents) - - -def _assert_join_profile( - identity: tuple[str, str, str], - relation: dict[str, Any], - profile: dict[str, Any], - documents: dict[str, Any], -) -> None: - expected = {item["role"]: item for item in profile["references"]} - actual = {item["role"]: item for item in relation["references"]} - if len(expected) != len(profile["references"]): - raise AssertionError(f"duplicate roles in relationship profile {profile['id']!r}") - if set(actual) != set(expected): - raise AssertionError( - f"relationship reference roles mismatch for {identity}: " - f"{sorted(actual)} != {sorted(expected)}" - ) - for role, endpoint in actual.items(): - _assert_endpoint_pattern(identity, endpoint, expected[role], documents) - - -def _assert_order_profile( - identity: tuple[str, str, str], - relation: dict[str, Any], - profile: dict[str, Any], - documents: dict[str, Any], -) -> None: - if relation["order_type"] != profile["order_type"]: - raise AssertionError( - f"relationship order type mismatch for {identity}: " - f"{relation['order_type']!r} != {profile['order_type']!r}" - ) - _assert_endpoint_pattern(identity, relation["before"], profile["before"], documents) - _assert_endpoint_pattern(identity, relation["after"], profile["after"], documents) - - -def _assert_endpoint_pattern( - identity: tuple[str, str, str], - endpoint: dict[str, str], - expected: dict[str, Any], - documents: dict[str, Any], -) -> None: - if endpoint["role"] != expected["role"] or endpoint["plane"] != expected["plane"]: - raise AssertionError( - f"relationship endpoint role/plane mismatch for {identity}: " - f"{endpoint!r} does not match {expected!r}" - ) - if not _pointer_matches_pattern(endpoint["pointer"], expected["pointer_pattern"]): - raise AssertionError( - f"relationship endpoint path mismatch for {identity}: " - f"{endpoint['pointer']!r} does not match {expected['pointer_pattern']!r}" - ) - qualifier = expected.get("qualifier") - if qualifier is not None: - pointer_parts = endpoint["pointer"].split("/")[1:] - levels_up = qualifier["levels_up"] - if levels_up > len(pointer_parts): - raise AssertionError( - f"relationship qualifier escapes endpoint root for {identity}" - ) - container_parts = pointer_parts[:-levels_up] - container_pointer = "/" + "/".join(container_parts) if container_parts else "" - container = resolve_json_pointer( - documents[endpoint["plane"]], container_pointer - ) - actual_qualifier = resolve_json_pointer( - container, qualifier["relative_pointer"] - ) - if actual_qualifier != qualifier["equals"]: - raise AssertionError( - f"relationship endpoint qualifier mismatch for {identity}: " - f"{actual_qualifier!r} != {qualifier['equals']!r}" - ) - - -def _rules_by_id(rules: list[dict[str, Any]], label: str) -> dict[str, dict[str, Any]]: - result = {rule["id"]: rule for rule in rules} - if len(result) != len(rules): - raise AssertionError(f"duplicate {label} rule id") - return result - - -def _relationship_rule( - rules: dict[str, dict[str, Any]], rule_id: str, fixture_set: str -) -> dict[str, Any]: - rule = rules.get(rule_id) - if rule is None: - raise AssertionError(f"unknown fixture relationship rule: {rule_id!r}") - if fixture_set not in rule["fixture_sets"]: - raise AssertionError(f"rule {rule_id!r} does not allow fixture set {fixture_set!r}") - return rule - - -def _resolve_scoped_reference( - documents: dict[str, Any], - reference: dict[str, str], - scopes: list[dict[str, str]], -) -> Any: - plane = reference["plane"] - pointer = reference["pointer"] - allowed = any( - scope["plane"] == plane and _pointer_has_prefix(pointer, scope["pointer_prefix"]) - for scope in scopes - ) - if not allowed: - raise AssertionError(f"reference is outside rule scope: {plane}{pointer}") - if plane not in documents: - raise AssertionError(f"reference targets an omitted plane: {plane!r}") - return resolve_json_pointer(documents[plane], pointer) - - -def _pointer_has_prefix(pointer: str, prefix: str) -> bool: - return not prefix or pointer == prefix or pointer.startswith(f"{prefix}/") - - -def _pointer_matches_pattern(pointer: str, pattern: str) -> bool: - pointer_parts = pointer.split("/")[1:] if pointer else [] - pattern_parts = pattern.split("/")[1:] if pattern else [] - return len(pointer_parts) == len(pattern_parts) and all( - expected == "*" or actual == expected - for actual, expected in zip(pointer_parts, pattern_parts, strict=True) - ) - - -def _resolve_pointer_pattern(document: Any, pattern: str) -> list[Any]: - segments = pattern.split("/")[1:] if pattern else [] - values = [document] - for encoded_segment in segments: - segment = encoded_segment.replace("~1", "/").replace("~0", "~") - matches: list[Any] = [] - for value in values: - if segment == "*": - if isinstance(value, dict): - matches.extend(value.values()) - elif isinstance(value, list): - matches.extend(value) - continue - if isinstance(value, dict) and segment in value: - matches.append(value[segment]) - continue - if isinstance(value, list) and segment.isdigit(): - index = int(segment) - if index < len(value): - matches.append(value[index]) - values = matches - if not values: - break - return values - - -def _find_forbidden_keys( - value: Any, - forbidden: set[str], - path: str = "", -) -> list[str]: - hits: list[str] = [] - if isinstance(value, dict): - for key, item in value.items(): - item_path = f"{path}/{key}" - if key in forbidden: - hits.append(item_path) - hits.extend(_find_forbidden_keys(item, forbidden, item_path)) - elif isinstance(value, list): - for index, item in enumerate(value): - hits.extend(_find_forbidden_keys(item, forbidden, f"{path}/{index}")) - return hits - - -def _ordered_value(value: Any, order_type: str) -> int | float | datetime: - if order_type == "integer": - if isinstance(value, bool) or not isinstance(value, int): - raise AssertionError(f"integer order value required: {value!r}") - return value - if order_type == "number": - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise AssertionError(f"numeric order value required: {value!r}") - return value - if not isinstance(value, str): - raise TypeError(f"RFC3339 order value required: {value!r}") - try: - parsed = datetime.fromisoformat(value) - except ValueError as exc: - raise AssertionError(f"invalid RFC3339 order value: {value!r}") from exc - if parsed.tzinfo is None: - raise AssertionError(f"RFC3339 order value must include timezone: {value!r}") - return parsed - - -def conformance_gate_failures(report: dict[str, Any]) -> list[str]: - failures: list[str] = [] - if report["legacy_characterization_status"] != "complete": - failures.append("legacy_characterization") - for field in ( - "schema_validation_results", - "scenario_plane_results", - "join_invariant_results", - "happens_before_results", - "http_sse_harness_results", - "frontend_baseline_results", - ): - gate_items = [ - item - for item in report[field] - if item["gate_required"] and item["status"] != "future_phase" - ] - if not gate_items or any( - item["status"] != "pass" or item["evidence_mode"] != "automated" - for item in gate_items - ): - failures.append(field) - for field in ( - "requirement_coverage", - "vocabulary_coverage", - "pairwise_coverage", - "seam_coverage", - "corpus_coverage", - ): - coverage = report[field] - if ( - coverage["status"] != "complete" - or coverage["covered"] != coverage["required"] - or coverage["missing"] - ): - failures.append(field) - if len(report["determinism_runs"]) < 2 or any( - not item["canonical_match"] for item in report["determinism_runs"] - ): - failures.append("determinism_runs") - if report["missing_assets"]: - failures.append("missing_assets") - reviews = report["reviews"] - if not reviews or any(item.get("runtime_gate") != "go" for item in reviews): - failures.append("reviews") - expected_decision = "no_go" if failures else "go" - if report["gate_decision"] != expected_decision: - failures.append("gate_decision") - if report["gate_decision"] == "go" and report["gate_reasons"]: - failures.append("gate_reasons") - if report["gate_decision"] == "no_go" and not report["gate_reasons"]: - failures.append("gate_reasons") - return failures - - -def manifest_closure_hash(contract_root: Path) -> str: - manifest = load_json(contract_root / "manifest.json") - corpus = load_json(contract_root / "legacy-skill-corpus.json") - paths = ( - {item["path"] for item in manifest["schemas"]} - | { - item["path"] - for item in manifest["instances"] - if item["path"] != "conformance-report-phase0.json" - } - | { - str(Path(item["fixture_path"]).relative_to("contracts/agent/v1")) - for item in corpus["entries"] - } - ) - fixture_paths = { - path.relative_to(contract_root).as_posix() - for path in (contract_root / "fixtures").rglob("*.json") - } - paths |= fixture_paths - entries = {path: f"sha256:{_file_hash(contract_root / path)}" for path in sorted(paths)} - source_paths = { - artifact["path"] - for fixture_path in fixture_paths - for artifact in load_json(contract_root / fixture_path)["source"]["artifacts"] - } - repo_root = contract_root.parents[2] - entries.update( - { - f"repo://{path}": f"sha256:{_file_hash(repo_root / path)}" - for path in sorted(source_paths) - } - ) - canonical = json.dumps(entries, sort_keys=True, separators=(",", ":")).encode() - return f"sha256:{hashlib.sha256(canonical).hexdigest()}" - - -def _validate( - instance: Any, - schema: dict[str, Any], - registry: Registry, - path: Path, -) -> None: - validator = Draft202012Validator( - schema, - registry=registry, - format_checker=FormatChecker(), - ) - errors = sorted(validator.iter_errors(instance), key=lambda error: list(error.absolute_path)) - assert not errors, "\n".join( - f"{path}:{'/'.join(map(str, error.absolute_path))}: {error.message}" for error in errors - ) - - -def _contains_key(value: Any, field: str) -> bool: - if isinstance(value, dict): - return field in value or any(_contains_key(item, field) for item in value.values()) - if isinstance(value, list): - return any(_contains_key(item, field) for item in value) - return False - - -def _assert_plane_semantics(expected: ExpectedFixture, payload: dict[str, Any]) -> None: - plane = expected.plane - if plane == "provider": - assert_contiguous_order(payload["exchanges"], "sequence") - return - if plane == "sse": - events = payload["events"] - assert_contiguous_order(events, "sequence") - durable_ids = [item["id"] for item in events if item["id"] is not None] - assert len(durable_ids) == len(set(durable_ids)), "duplicate durable SSE event id" - if ( - expected.fixture_set == "legacy_characterization" - and expected.variant_id == "GT13-llm-error" - ): - names = [item["event"] for item in events] - assert names.count("error_occurred") == 1 - assert "complete" not in names - assert names.index("error_occurred") < names.index("stream_end") - assert names[-2:] == ["assistant_message_created", "session_state_changed"] - return - terminals = [ - index for index, item in enumerate(events) if item["event"] in TERMINAL_SSE_EVENTS - ] - assert len(terminals) == 1, f"expected one terminal SSE event: {terminals}" - assert terminals[0] == len(events) - 1, "terminal SSE event must be last" - return - if plane == "db_events": - events = payload["events"] - assert_contiguous_order(events, "observed_row_order") - event_ids = [item["event_id"] for item in events] - assert len(event_ids) == len(set(event_ids)), "duplicate DB event id" - assert_monotonic_timestamps(events, "created_at") - - -def _payload_hash(payload: Any) -> str: - canonical = ( - json.dumps( - payload, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - + b"\n" - ) - return f"sha256:{hashlib.sha256(canonical).hexdigest()}" - - -def _file_hash(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() diff --git a/backend/tests/agent_golden/fixture_writer.py b/backend/tests/agent_golden/fixture_writer.py deleted file mode 100644 index 303a684a..00000000 --- a/backend/tests/agent_golden/fixture_writer.py +++ /dev/null @@ -1,642 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import subprocess -from pathlib import Path -from typing import Any - -from agent_golden.contract_validation import ExpectedFixture, expected_fixture_matrix -from agent_golden.harness import GoldenHarness -from agent_golden.legacy_scenario_capture import ( - LegacyScenarioCapture, - execute_legacy_variant, -) -from agent_golden.support import CanonicalNormalizer, load_json - - -def capture_gt01_legacy_envelopes( - contract_root: Path, - harness: GoldenHarness, - variant_id: str, -) -> dict[str, dict[str, Any]]: - if variant_id not in {"GT01-sync", "GT01-sse"}: - raise ValueError(f"unsupported GT01 fixture variant: {variant_id!r}") - return capture_legacy_envelopes(contract_root, harness, variant_id) - - -def capture_legacy_envelopes( - contract_root: Path, - harness: GoldenHarness, - variant_id: str, - monkeypatch: Any | None = None, - *, - captured_revision: str | None = None, -) -> dict[str, dict[str, Any]]: - scenario = execute_legacy_variant(contract_root.parents[2], harness, variant_id, monkeypatch) - - raw_planes = _raw_planes(harness, scenario) - planes = CanonicalNormalizer.from_profile( - contract_root / "normalization-profiles.json", - "agent-golden-v1", - rfc3339_timestamps=True, - ).normalize(raw_planes) - relationship_indices = _relationship_indices(planes, scenario.client_turn_id) - matrix = expected_fixture_matrix(contract_root) - expected = { - item.plane: item - for item in matrix.values() - if item.fixture_set == "legacy_characterization" and item.variant_id == variant_id - } - return { - plane: _envelope( - contract_root, - item, - planes.get(plane), - relationship_indices, - captured_revision or _repo_revision(contract_root), - ) - for plane, item in expected.items() - } - - -def write_envelopes(envelopes: dict[str, dict[str, Any]], expected: dict[str, Path]) -> None: - for plane, envelope in envelopes.items(): - path = expected[plane] - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - - -def _raw_planes( - harness: GoldenHarness, - scenario: LegacyScenarioCapture, -) -> dict[str, Any]: - capture = scenario.http - rows = harness.database_rows(capture.session_id) - history = harness.history(capture.session_id) - public_session = harness.public_session(capture.session_id) - response = capture.response or next( - (item.data for item in capture.sse_events if item.event == "complete"), - {}, - ) - user_event = _unique_matching_event( - rows["events"], - lambda item: ( - item["event_type"] == "user_message_received" - and item["payload"].get("client_turn_id") == scenario.client_turn_id - ), - f"DB user event for {scenario.client_turn_id}", - ) - user_message_id = user_event["payload"]["message_id"] - assistant_event = _unique_matching_event( - rows["events"], - lambda item: ( - item["event_type"] == "assistant_message_created" - and ( - item["payload"].get("client_turn_id") == scenario.client_turn_id - or item["payload"].get("turn_id") == user_message_id - ) - ), - f"DB assistant event for {scenario.client_turn_id}", - ) - assistant_message_id = assistant_event["payload"]["message_id"] - assistant_message = next(item for item in history if item["id"] == assistant_message_id) - event_names = [item.event for item in capture.sse_events] - if not event_names: - termination = "sync_response" - elif "complete" in event_names: - termination = "complete" - elif "error_occurred" in event_names: - termination = "legacy_error_then_clean_close" - else: - termination = event_names[-1] - return { - "domain": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": scenario.message, - "client_turn_id": scenario.client_turn_id, - "turn_id": user_message_id, - **scenario.request_extra, - }, - "router_decision": response.get("router_decision"), - "step_result": response.get("step_result"), - "tool_result": response.get("tool_result"), - "outcome": { - "reply": response.get("reply") or assistant_message["content"], - "session_id": capture.session_id, - "assistant_message_id": assistant_message_id, - }, - "facts": scenario.facts, - "termination": termination, - }, - "sse": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": capture.status_code, - "content_type": capture.content_type, - }, - "events": [ - {"sequence": index, "id": item.id, "event": item.event, "data": item.data} - for index, item in enumerate(capture.sse_events) - ], - }, - "db_events": {"events": rows["events"]}, - "conversation": { - "sync_response": capture.response, - "messages": history, - "session": public_session, - "persisted_pre_state": scenario.persisted_pre_state, - "persisted_session": rows["session"], - "interaction_checks": scenario.interaction_checks, - }, - } - - -def _envelope( - contract_root: Path, - expected: ExpectedFixture, - payload: dict[str, Any] | None, - relationship_indices: dict[str, int], - captured_revision: str, -) -> dict[str, Any]: - required = expected.applicability == "required" - observation_fields = set( - load_json(contract_root / "field-ownership.json")["legacy_field_observation"][ - "expectations" - ] - ) - source_paths = ( - [ - "backend/tests/agent_golden/fixture_writer.py", - "backend/tests/agent_golden/legacy_scenario_capture.py", - "backend/tests/agent_golden/harness.py", - "backend/tests/agent_golden/support.py", - "backend/tests/agent_golden/scripted_dependencies.py", - "contracts/agent/v1/normalization-profiles.json", - "contracts/agent/v1/scenario-catalog.json", - ] - if required - else ["contracts/agent/v1/scenario-catalog.json"] - ) - source = { - "kind": "captured_runtime" if required else "explicit_manifest", - "repo_revision": captured_revision, - "artifacts": [ - { - "path": path, - "sha256": f"sha256:{hashlib.sha256((contract_root.parents[2] / path).read_bytes()).hexdigest()}", - } - for path in source_paths - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - if required - else None, - } - envelope: dict[str, Any] = { - "schema_version": "1.0", - "fixture_set": expected.fixture_set, - "scenario_id": expected.scenario_id, - "variant_id": expected.variant_id, - "plane": expected.plane, - "applicability": expected.applicability, - "payload_schema": _payload_schema(expected.plane) if required else None, - "normalization_profile": "agent-golden-v1", - "source": source, - "legacy_field_observation": { - field: "present" if required and _contains_key(payload, field) else "absent" - for field in sorted(observation_fields) - }, - "content_hash": _payload_hash(payload) if required else None, - "joins": [], - "happens_before": [], - } - if required: - assert payload is not None - envelope["payload"] = payload - _add_relationships( - envelope, - expected.variant_id, - expected.plane, - relationship_indices, - ) - else: - envelope["omission"] = { - "reason": f"{expected.plane} is {expected.applicability} for {expected.variant_id}." - } - return envelope - - -def _add_relationships( - envelope: dict[str, Any], - variant_id: str, - plane: str, - indices: dict[str, Any], -) -> None: - if plane == "domain": - envelope["joins"] = [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id", - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": f"/events/{indices['db_user']}/payload/message_id", - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": f"/messages/{indices['conversation_user']}/id", - }, - ], - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id", - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": f"/events/{indices['db_assistant']}/payload/message_id", - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": f"/messages/{indices['conversation_assistant']}/id", - }, - ], - }, - ] - if variant_id == "GT16-history": - envelope["joins"].append( - { - "name": "attachment-upload-request-history-identity", - "rule_id": "legacy.attachment_identity", - "references": [ - { - "role": "domain_request_attachment_id", - "plane": "domain", - "pointer": "/request/attachments/0/id", - }, - { - "role": "conversation_message_attachment_id", - "plane": "conversation", - "pointer": ( - f"/messages/{indices['conversation_user']}" - "/metadata/attachments/0/id" - ), - }, - { - "role": "action_persisted_attachment_id", - "plane": "conversation", - "pointer": ( - "/interaction_checks/0/action_result/persisted_state/attachment/id" - ), - }, - ], - } - ) - if variant_id in {"GT03-true", "GT03-false"}: - envelope["joins"].append( - { - "name": "selected-graph-step-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_selected_step_id", - "plane": "domain", - "pointer": "/facts/0/selected_step_id", - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": ( - f"/events/{indices['db_skill_step_changed'][0]}" - "/payload/to_step_id" - ), - }, - ], - } - ) - if variant_id == "GT04-merge": - for transition_index, db_index in enumerate( - indices["db_skill_step_changed"] - ): - envelope["joins"].append( - { - "name": f"graph-transition-step-{transition_index + 1}-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_transition_step_id", - "plane": "domain", - "pointer": ( - f"/facts/0/step_transitions/{transition_index}/" - "to_step_id" - ), - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": f"/events/{db_index}/payload/to_step_id", - }, - ], - } - ) - for pending_index, db_index in enumerate( - indices["db_graph_pending_steps_updated"] - ): - envelope["joins"].append( - { - "name": f"graph-pending-snapshot-{pending_index + 1}-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": ( - f"/facts/0/pending_step_updates/{pending_index}/" - "pending_step_ids" - ), - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": ( - f"/events/{db_index}/payload/pending_step_ids" - ), - }, - ], - } - ) - if plane == "db_events": - envelope["happens_before"] = [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": f"/events/{indices['db_user']}/observed_row_order", - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": f"/events/{indices['db_assistant']}/observed_row_order", - }, - } - ] - if plane == "conversation" and variant_id == "GT01-feedback-refresh-toggle": - envelope["joins"] = [ - { - "name": "feedback-target-message-identity", - "rule_id": "legacy.feedback_target_identity", - "references": [ - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": f"/messages/{indices['conversation_assistant']}/id", - }, - { - "role": "action_feedback_target_id", - "plane": "conversation", - "pointer": "/interaction_checks/0/action_result/resource_id", - }, - { - "role": "feedback_response_message_id", - "plane": "conversation", - "pointer": ( - "/interaction_checks/0/action_result/persisted_state/" - "up_response/message_id" - ), - }, - ], - } - ] - if plane == "sse": - occurrences: dict[str, int] = {} - joins = [] - for sse_index, db_index in indices.get("durable_sse_db", {}).items(): - event_name = indices["sse_event_names"][sse_index].replace("_", "-") - occurrence = occurrences.get(event_name, 0) + 1 - occurrences[event_name] = occurrence - suffix = "" if occurrence == 1 else f"-{occurrence}" - joins.append( - { - "name": f"durable-{event_name}-event{suffix}-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": f"/events/{sse_index}/id", - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": f"/events/{db_index}/event_id", - }, - ], - } - ) - envelope["joins"] = joins - if plane == "sse" and "sse_complete" in indices: - envelope["happens_before"] = [ - { - "name": "user-event-before-complete", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_user_event_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_user']}/sequence", - }, - "after": { - "role": "sse_complete_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_complete']}/sequence", - }, - } - ] - if plane == "sse" and variant_id == "GT13-llm-error": - envelope["happens_before"] = [ - { - "name": "error-before-stream-end", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_error_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_error']}/sequence", - }, - "after": { - "role": "sse_stream_end_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_stream_end']}/sequence", - }, - }, - { - "name": "stream-end-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_stream_end_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_stream_end']}/sequence", - }, - "after": { - "role": "sse_assistant_event_sequence", - "plane": "sse", - "pointer": f"/events/{indices['sse_assistant']}/sequence", - }, - }, - ] - - -def _relationship_indices(planes: dict[str, Any], client_turn_id: str) -> dict[str, Any]: - db_events = planes["db_events"]["events"] - db_user = _unique_matching_index( - db_events, - lambda event: ( - event.get("event_type") == "user_message_received" - and event.get("payload", {}).get("client_turn_id") == client_turn_id - ), - "DB user event", - ) - turn_id = db_events[db_user]["payload"]["message_id"] - db_assistant = _unique_matching_index( - db_events, - lambda event: ( - event.get("event_type") == "assistant_message_created" - and ( - event.get("payload", {}).get("client_turn_id") == client_turn_id - or event.get("payload", {}).get("turn_id") == turn_id - ) - ), - "DB assistant event", - ) - assistant_id = db_events[db_assistant]["payload"]["message_id"] - messages = planes["conversation"]["messages"] - result = { - "db_user": db_user, - "db_assistant": db_assistant, - "conversation_user": _unique_matching_index( - messages, lambda message: message.get("id") == turn_id, "conversation user message" - ), - "conversation_assistant": _unique_matching_index( - messages, - lambda message: message.get("id") == assistant_id, - "conversation assistant message", - ), - "db_skill_step_changed": [ - index - for index, event in enumerate(db_events) - if event.get("event_type") == "skill_step_changed" - ], - "db_graph_pending_steps_updated": [ - index - for index, event in enumerate(db_events) - if event.get("event_type") == "graph_pending_steps_updated" - ], - } - sse_events = planes["sse"]["events"] - if sse_events: - result["sse_event_names"] = [event["event"] for event in sse_events] - result["durable_sse_db"] = { - index: _unique_matching_index( - db_events, - lambda db_event, event_id=event["id"]: db_event.get("event_id") == event_id, - f"DB event for SSE event {index}", - ) - for index, event in enumerate(sse_events) - if event.get("id") is not None - } - result["sse_user"] = _unique_matching_index( - sse_events, - lambda event: ( - event.get("event") == "user_message_received" - and event.get("data", {}).get("client_turn_id") == client_turn_id - ), - "SSE user event", - ) - complete = [ - index for index, event in enumerate(sse_events) if event.get("event") == "complete" - ] - if complete: - if len(complete) != 1: - raise AssertionError(f"expected at most one SSE complete event: {complete}") - result["sse_complete"] = complete[0] - if any(event.get("event") == "error_occurred" for event in sse_events): - for key, event_name in ( - ("sse_error", "error_occurred"), - ("sse_stream_end", "stream_end"), - ("sse_assistant", "assistant_message_created"), - ): - result[key] = _unique_matching_index( - sse_events, - lambda event, expected=event_name: event.get("event") == expected, - f"SSE {event_name} event", - ) - return result - - -def _unique_matching_index(items: list[dict[str, Any]], predicate: Any, label: str) -> int: - matches = [index for index, item in enumerate(items) if predicate(item)] - if len(matches) != 1: - raise AssertionError(f"expected exactly one {label}, found {len(matches)}") - return matches[0] - - -def _unique_matching_event( - events: list[dict[str, Any]], predicate: Any, label: str -) -> dict[str, Any]: - return events[_unique_matching_index(events, predicate, label)] - - -def _payload_schema(plane: str) -> str: - return { - "provider": "planes/legacy-provider-exchange.schema.json", - "domain": "planes/domain.schema.json", - "sse": "planes/sse.schema.json", - "db_events": "planes/db-events.schema.json", - "conversation": "planes/conversation.schema.json", - }[plane] - - -def _payload_hash(payload: Any) -> str: - canonical = ( - json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() - + b"\n" - ) - return f"sha256:{hashlib.sha256(canonical).hexdigest()}" - - -def _contains_key(value: Any, field: str) -> bool: - if isinstance(value, dict): - return field in value or any(_contains_key(item, field) for item in value.values()) - if isinstance(value, list): - return any(_contains_key(item, field) for item in value) - return False - - -def _repo_revision(contract_root: Path) -> str: - return subprocess.check_output( - ["git", "rev-parse", "HEAD"], cwd=contract_root.parents[2], text=True - ).strip() diff --git a/backend/tests/agent_golden/harness.py b/backend/tests/agent_golden/harness.py deleted file mode 100644 index 6aa2e587..00000000 --- a/backend/tests/agent_golden/harness.py +++ /dev/null @@ -1,377 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy import event -from sqlmodel import Session, SQLModel, create_engine, select - -from agent_golden.scripted_dependencies import ScriptedLLMPlan, install_scripted_llm -from agent_golden.sse import SSEEvent, parse_sse_lines -from app.api import chat as chat_api -from app.api import scheduled_tasks as scheduled_tasks_api -from app.core.agent_loop import AgentLoop -from app.db import get_session -from app.db.models import ( - AgentEvent, - AgentProfile, - AgentResourceBinding, - ChatSession, - Message, - ModelConfig, - Skill, - Tenant, - User, -) -from app.security.auth import create_access_token - -TENANT_ID = "tenant_golden" -USER_ID = "user_golden" -AGENT_ID = "agent_golden" -MODEL_ID = "model_golden" - - -@dataclass(frozen=True) -class HttpCapture: - status_code: int - content_type: str - response: dict[str, Any] | None - sse_events: list[SSEEvent] - session_id: str - - -class GoldenHarness: - def __init__(self, database_path: Path, monkeypatch: Any, plan: ScriptedLLMPlan) -> None: - self.database_path = database_path - self.engine = create_engine( - f"sqlite:///{database_path}", - connect_args={"check_same_thread": False, "timeout": 30}, - ) - self._configure_sqlite() - SQLModel.metadata.create_all(self.engine) - self._seed() - - install_scripted_llm(monkeypatch, plan) - monkeypatch.setattr(chat_api, "engine", self.engine) - monkeypatch.setattr(chat_api, "_schedule_session_title_summary", lambda *_args: None) - monkeypatch.setattr(chat_api, "enqueue_feedback_analysis", lambda *_args: None) - monkeypatch.setattr(AgentLoop, "_enqueue_memory_capture", lambda *_args, **_kwargs: None) - monkeypatch.setattr(AgentLoop, "_pace_stream", lambda *_args: None) - - self.app = FastAPI() - self.app.include_router(chat_api.router) - self.app.include_router(scheduled_tasks_api.chat_router) - self.app.dependency_overrides[get_session] = self._session_dependency - self.client = TestClient(self.app) - with Session(self.engine) as db: - user = db.get(User, USER_ID) - assert user is not None - self.token = create_access_token(user) - - def close(self) -> None: - self.client.close() - self.engine.dispose() - - @property - def headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self.token}"} - - def turn_payload( - self, - message: str, - *, - client_turn_id: str, - session_id: str | None = None, - ) -> dict[str, Any]: - payload: dict[str, Any] = { - "tenant_id": TENANT_ID, - "agent_id": AGENT_ID, - "message": message, - "client_turn_id": client_turn_id, - } - if session_id: - payload["session_id"] = session_id - return payload - - def post_sync(self, payload: dict[str, Any]) -> HttpCapture: - response = self.client.post("/api/chat/turn", headers=self.headers, json=payload) - body = response.json() - assert isinstance(body, dict) - return HttpCapture( - status_code=response.status_code, - content_type=response.headers.get("content-type", ""), - response=body, - sse_events=[], - session_id=str(body.get("session_id") or ""), - ) - - def post_stream(self, payload: dict[str, Any]) -> HttpCapture: - with self.client.stream( - "POST", - "/api/chat/stream", - headers=self.headers, - json=payload, - ) as response: - events = parse_sse_lines(response.iter_lines()) - status_code = response.status_code - content_type = response.headers.get("content-type", "") - session_id = next( - ( - str(item.data.get("sessionId") or item.data.get("session_id") or "") - for item in events - if item.data.get("sessionId") or item.data.get("session_id") - ), - "", - ) - return HttpCapture( - status_code=status_code, - content_type=content_type, - response=None, - sse_events=events, - session_id=session_id, - ) - - def history(self, session_id: str) -> list[dict[str, Any]]: - response = self.client.get( - f"/api/chat/sessions/{session_id}/messages", - headers=self.headers, - params={"tenant_id": TENANT_ID}, - ) - assert response.status_code == 200, response.text - body = response.json() - assert isinstance(body, list) - return body - - def set_feedback(self, message_id: str, rating: str) -> tuple[int, dict[str, Any]]: - response = self.client.post( - f"/api/chat/messages/{message_id}/feedback", - headers=self.headers, - json={"tenant_id": TENANT_ID, "rating": rating}, - ) - body = response.json() - assert isinstance(body, dict) - return response.status_code, body - - def clear_feedback(self, message_id: str) -> tuple[int, dict[str, Any]]: - response = self.client.delete( - f"/api/chat/messages/{message_id}/feedback", - headers=self.headers, - params={"tenant_id": TENANT_ID}, - ) - body = response.json() - assert isinstance(body, dict) - return response.status_code, body - - def upload_text_attachment(self, filename: str, content: bytes) -> list[dict[str, Any]]: - response = self.client.post( - "/api/chat/attachments", - headers=self.headers, - params={"tenant_id": TENANT_ID}, - files={"files": (filename, content, "text/plain")}, - ) - assert response.status_code == 200, response.text - body = response.json() - assert isinstance(body, list) - return body - - def publish_scene_skill(self, content: dict[str, Any]) -> None: - with Session(self.engine) as db: - skill = Skill( - tenant_id=TENANT_ID, - skill_id=str(content["skill_id"]), - version=str(content["version"]), - name=str(content["name"]), - description=content.get("description"), - business_domain=content.get("business_domain"), - content_json=content, - status="published", - ) - db.add(skill) - db.flush() - db.add( - AgentResourceBinding( - tenant_id=TENANT_ID, - agent_id=AGENT_ID, - resource_type="skill", - resource_id=skill.id, - status="active", - metadata_json={ - "scope": "agent_private", - "visibility": "agent_private", - "owner_agent_id": AGENT_ID, - }, - ) - ) - db.commit() - - def create_persisted_session( - self, - session_id: str, - *, - active_skill_id: str, - active_step_id: str, - slots: dict[str, Any] | None = None, - ) -> dict[str, Any]: - with Session(self.engine) as db: - row = ChatSession( - id=session_id, - tenant_id=TENANT_ID, - user_id=USER_ID, - agent_id=AGENT_ID, - active_skill_id=active_skill_id, - active_step_id=active_step_id, - slots_json=slots or {}, - ) - db.add(row) - db.commit() - return self.database_rows(session_id)["session"] - - def create_scheduled_task(self, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: - response = self.client.post( - "/api/chat/scheduled-tasks", - headers=self.headers, - json=payload, - ) - body = response.json() - assert isinstance(body, dict) - return response.status_code, body - - def list_scheduled_tasks(self) -> list[dict[str, Any]]: - response = self.client.get( - "/api/chat/scheduled-tasks", - headers=self.headers, - params={"tenant_id": TENANT_ID, "agent_id": AGENT_ID}, - ) - assert response.status_code == 200, response.text - body = response.json() - assert isinstance(body, list) - return body - - def public_session(self, session_id: str) -> dict[str, Any]: - response = self.client.get( - "/api/chat/sessions", - headers=self.headers, - params={"tenant_id": TENANT_ID}, - ) - assert response.status_code == 200, response.text - return next(item for item in response.json() if item["id"] == session_id) - - def session_events(self, session_id: str) -> list[dict[str, Any]]: - response = self.client.get( - f"/api/chat/sessions/{session_id}/events", - headers=self.headers, - params={"tenant_id": TENANT_ID}, - ) - assert response.status_code == 200, response.text - body = response.json() - assert isinstance(body, list) - return body - - def database_rows(self, session_id: str) -> dict[str, Any]: - with Session(self.engine) as db: - session = db.get(ChatSession, session_id) - assert session is not None - messages = db.exec( - select(Message) - .where(Message.tenant_id == TENANT_ID, Message.session_id == session_id) - .order_by(Message.created_at, Message.id) - ).all() - events = db.exec( - select(AgentEvent) - .where(AgentEvent.tenant_id == TENANT_ID, AgentEvent.session_id == session_id) - .order_by(AgentEvent.created_at, AgentEvent.id) - ).all() - return { - "session": _session_row(session), - "messages": [_message_row(item) for item in messages], - "events": [_event_row(index, item) for index, item in enumerate(events)], - } - - def _configure_sqlite(self) -> None: - @event.listens_for(self.engine, "connect") - def configure_connection(dbapi_connection: Any, _connection_record: Any) -> None: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.execute("PRAGMA busy_timeout=30000") - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - def _session_dependency(self): - with Session(self.engine) as db: - yield db - - def _seed(self) -> None: - with Session(self.engine) as db: - db.add(Tenant(id=TENANT_ID, name="Golden Tenant")) - db.add( - User( - id=USER_ID, - tenant_id=TENANT_ID, - username="golden", - password_hash="unused", - ) - ) - db.add( - AgentProfile( - id=AGENT_ID, - tenant_id=TENANT_ID, - name="Golden Agent", - is_overall=False, - metadata_json={"owner_user_id": USER_ID}, - ) - ) - db.add( - ModelConfig( - id=MODEL_ID, - tenant_id=TENANT_ID, - name="Golden Model", - api_key_encrypted="unused", - model="golden-model", - is_default=True, - ) - ) - db.commit() - - -def _session_row(row: ChatSession) -> dict[str, Any]: - return { - "id": row.id, - "tenant_id": row.tenant_id, - "user_id": row.user_id, - "agent_id": row.agent_id, - "status": row.status, - "active_skill_id": row.active_skill_id, - "active_step_id": row.active_step_id, - "slots": row.slots_json, - "awaiting_input": row.awaiting_input_json, - "pending_tasks": row.pending_tasks_json, - "created_at": row.created_at.isoformat(), - "updated_at": row.updated_at.isoformat(), - } - - -def _message_row(row: Message) -> dict[str, Any]: - return { - "id": row.id, - "tenant_id": row.tenant_id, - "session_id": row.session_id, - "role": row.role, - "content": row.content, - "metadata": row.metadata_json, - "created_at": row.created_at.isoformat(), - } - - -def _event_row(observed_row_order: int, row: AgentEvent) -> dict[str, Any]: - return { - "observed_row_order": observed_row_order, - "event_id": row.id, - "event_type": row.event_type, - "tenant_id": row.tenant_id, - "session_id": row.session_id, - "created_at": row.created_at.isoformat(), - "payload": row.payload_json, - } diff --git a/backend/tests/agent_golden/legacy_scenario_capture.py b/backend/tests/agent_golden/legacy_scenario_capture.py deleted file mode 100644 index 71bb99c8..00000000 --- a/backend/tests/agent_golden/legacy_scenario_capture.py +++ /dev/null @@ -1,717 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from agent_golden.harness import GoldenHarness, HttpCapture -from agent_golden.scripted_dependencies import ScriptedLLMClient, ScriptedLLMPlan - - -@dataclass(frozen=True) -class LegacyScenarioCapture: - http: HttpCapture - message: str - client_turn_id: str - request_extra: dict[str, Any] = field(default_factory=dict) - persisted_pre_state: dict[str, Any] | None = None - interaction_checks: list[dict[str, Any]] = field(default_factory=list) - facts: list[dict[str, Any]] = field(default_factory=list) - - -def plan_for_variant(variant_id: str) -> ScriptedLLMPlan: - if variant_id == "GT13-llm-error": - return ScriptedLLMPlan(fail_phases={"Router"}) - if variant_id == "GT02-ask-refresh-continue": - return ScriptedLLMPlan( - json_by_phase_and_message={ - "Router": { - "我要购买 A1。": { - "decision": "start_new_task", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "购买 A1", - "reason": "Start purchase flow.", - "slot_hints": {"product_id": "A1"}, - }, - "我是小明,要买两件。": { - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - }, - }, - "Step Agent": { - "我要购买 A1。": { - "action": "ask_user", - "reply": "请告诉我您的姓名和购买数量。", - "slot_updates": {"product_id": "A1"}, - "is_step_completed": False, - }, - "我是小明,要买两件。": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2, - }, - "next_step_id": "confirm_purchase", - "is_step_completed": False, - }, - }, - } - ) - if variant_id in {"GT03-true", "GT03-false"}: - selected_step = "approve" if variant_id == "GT03-true" else "reject" - return ScriptedLLMPlan( - json_by_phase={ - "Router": { - "decision": "continue_active", - "target_skill_id": "skill_conditional_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "审核报文", - "reason": "Exercise the selected exclusive branch.", - "slot_hints": {"message_content": "Golden 审核报文"}, - } - }, - json_sequence_by_phase={ - "Step Agent": ( - { - "action": "advance", - "reply": "审核分支已选择。", - "slot_updates": {"message_content": "Golden 审核报文"}, - "next_step_id": selected_step, - "is_step_completed": True, - }, - { - "action": "reply", - "reply": "审核结果已确认。", - "is_step_completed": True, - }, - ) - }, - ) - if variant_id == "GT04-merge": - return ScriptedLLMPlan( - json_by_phase={ - "Router": { - "decision": "continue_active", - "target_skill_id": "skill_parallel_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "并行审核报文", - "reason": "Exercise sibling ordering and merge.", - "slot_hints": {"message_content": "Golden 并行审核报文"}, - } - }, - json_sequence_by_phase={ - "Step Agent": ( - { - "action": "advance", - "reply": "开始收款方检查。", - "slot_updates": {"message_content": "Golden 并行审核报文"}, - "next_step_id": "check_payee", - "is_step_completed": True, - }, - { - "action": "advance", - "reply": "收款方检查完成。", - "next_step_id": "report", - "is_step_completed": True, - }, - { - "action": "advance", - "reply": "敏感词检查完成。", - "next_step_id": "report", - "is_step_completed": True, - }, - { - "action": "reply", - "reply": "并行审核报告已生成。", - "is_step_completed": True, - }, - ) - }, - ) - return ScriptedLLMPlan() - - -def execute_legacy_variant( - repo_root: Path, - harness: GoldenHarness, - variant_id: str, - monkeypatch: Any | None = None, -) -> LegacyScenarioCapture: - if variant_id == "GT01-sync": - return _plain_chat( - harness, - message="你好,请介绍一下自己。", - client_turn_id="client-gt01-sync", - stream=False, - ) - if variant_id == "GT01-sse": - return _plain_chat( - harness, - message="你好,请流式回复。", - client_turn_id="client-gt01-sse", - stream=True, - ) - if variant_id == "GT01-feedback-refresh-toggle": - return _feedback_refresh_toggle(harness) - if variant_id == "GT02-ask-refresh-continue": - return _sop_refresh_continue(repo_root, harness) - if variant_id in {"GT03-true", "GT03-false"}: - return _conditional_branch(harness, variant_id) - if variant_id == "GT04-merge": - return _parallel_sibling_merge(harness) - if variant_id == "GT13-llm-error": - return _llm_error(repo_root, harness) - if variant_id == "GT15-full": - if monkeypatch is None: - raise ValueError("GT15-full capture requires monkeypatch") - return _scheduled_draft(harness, monkeypatch) - if variant_id == "GT16-history": - return _attachment_history(harness) - raise ValueError(f"unsupported legacy fixture variant: {variant_id!r}") - - -def _plain_chat( - harness: GoldenHarness, - *, - message: str, - client_turn_id: str, - stream: bool, -) -> LegacyScenarioCapture: - payload = harness.turn_payload(message, client_turn_id=client_turn_id) - http = harness.post_stream(payload) if stream else harness.post_sync(payload) - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - interaction_checks=[_none_interaction()], - ) - - -def _feedback_refresh_toggle(harness: GoldenHarness) -> LegacyScenarioCapture: - message = "请回复后接受评价。" - client_turn_id = "client-feedback" - http = harness.post_sync(harness.turn_payload(message, client_turn_id=client_turn_id)) - initial_history = harness.history(http.session_id) - assistant_message_id = initial_history[-1]["id"] - up_status, up_response = harness.set_feedback(assistant_message_id, "up") - up_history = harness.history(http.session_id) - invalid_status, invalid_response = harness.set_feedback(assistant_message_id, "invalid") - invalid_history = harness.history(http.session_id) - clear_status, clear_response = harness.clear_feedback(assistant_message_id) - cleared_history = harness.history(http.session_id) - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - interaction_checks=[ - { - "kind": "feedback", - "realtime_observation": "not_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "feedback-up-invalid-clear", - "evidence_origin": "harness_synthetic", - "resource_id": assistant_message_id, - "action": "rate_up_reject_invalid_clear", - "request": { - "set_rating": "up", - "invalid_rating": "invalid", - "clear": True, - }, - "response_status": clear_status, - "persisted_state": { - "initial_rating": initial_history[-1]["feedback_rating"], - "up_status": up_status, - "up_response": up_response, - "rating_after_up_refresh": up_history[-1]["feedback_rating"], - "invalid_status": invalid_status, - "invalid_response": invalid_response, - "rating_after_invalid_refresh": invalid_history[-1]["feedback_rating"], - "clear_response": clear_response, - "rating_after_clear_refresh": cleared_history[-1]["feedback_rating"], - }, - }, - } - ], - facts=[ - { - "kind": "feedback_history_lifecycle", - "assistant_message_id": assistant_message_id, - "db_event_types": [ - item["event_type"] - for item in harness.session_events(http.session_id) - if item["event_type"] == "message_feedback_changed" - ], - } - ], - ) - - -def _llm_error(repo_root: Path, harness: GoldenHarness) -> LegacyScenarioCapture: - harness.publish_scene_skill( - json.loads( - (repo_root / "contracts/agent/v1/corpus/production_seed/purchase.json").read_text( - encoding="utf-8" - ) - ) - ) - message = "触发模型异常" - client_turn_id = "client-llm-error" - http = harness.post_stream(harness.turn_payload(message, client_turn_id=client_turn_id)) - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - interaction_checks=[_none_interaction()], - ) - - -def _sop_refresh_continue(repo_root: Path, harness: GoldenHarness) -> LegacyScenarioCapture: - harness.publish_scene_skill( - json.loads( - (repo_root / "contracts/agent/v1/corpus/production_seed/purchase.json").read_text( - encoding="utf-8" - ) - ) - ) - first = harness.post_stream( - harness.turn_payload("我要购买 A1。", client_turn_id="client-gt02-ask") - ) - first_history = harness.history(first.session_id) - first_session = harness.public_session(first.session_id) - message = "我是小明,要买两件。" - client_turn_id = "client-gt02-continue" - second = harness.post_stream( - harness.turn_payload( - message, - client_turn_id=client_turn_id, - session_id=first.session_id, - ) - ) - return LegacyScenarioCapture( - http=second, - message=message, - client_turn_id=client_turn_id, - request_extra={"session_id": first.session_id}, - interaction_checks=[_none_interaction()], - facts=[ - { - "kind": "first_turn_refresh_state", - "first_turn_terminal": first.sse_events[-1].event, - "assistant_reply": first_history[-1]["content"], - "active_skill_id": first_session["active_skill_id"], - "active_step_id": first_session["active_step_id"], - "awaiting_input": first_session.get("awaiting_input"), - } - ], - ) - - -def _conditional_branch( - harness: GoldenHarness, - variant_id: str, -) -> LegacyScenarioCapture: - harness.publish_scene_skill(_audit_graph_skill(parallel=False)) - selected_step = "approve" if variant_id == "GT03-true" else "reject" - message = "审核通过。" if variant_id == "GT03-true" else "审核拒绝。" - client_turn_id = f"client-{variant_id.lower()}" - session_id = f"session-{variant_id.lower()}" - pre_state = harness.create_persisted_session( - session_id, - active_skill_id="skill_conditional_audit", - active_step_id="start", - ) - http = harness.post_sync( - harness.turn_payload( - message, - client_turn_id=client_turn_id, - session_id=session_id, - ) - ) - events = harness.session_events(http.session_id) - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - request_extra={"session_id": session_id}, - persisted_pre_state=pre_state, - interaction_checks=[_none_interaction()], - facts=[ - { - "kind": "exclusive_graph_branch", - "observation_source": "session_events_api", - "selected_step_id": selected_step, - "step_transitions": _event_payloads(events, "skill_step_changed"), - "pending_step_updates": _event_payloads( - events, "graph_pending_steps_updated" - ), - "llm_phase_order": _llm_phase_order(), - } - ], - ) - - -def _parallel_sibling_merge(harness: GoldenHarness) -> LegacyScenarioCapture: - harness.publish_scene_skill(_audit_graph_skill(parallel=True)) - message = "并行检查这条报文。" - client_turn_id = "client-gt04-merge" - session_id = "session-gt04-merge" - pre_state = harness.create_persisted_session( - session_id, - active_skill_id="skill_parallel_audit", - active_step_id="start", - ) - http = harness.post_sync( - harness.turn_payload( - message, - client_turn_id=client_turn_id, - session_id=session_id, - ) - ) - events = harness.session_events(http.session_id) - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - request_extra={"session_id": session_id}, - persisted_pre_state=pre_state, - interaction_checks=[_none_interaction()], - facts=[ - { - "kind": "parallel_graph_merge", - "observation_source": "session_events_api", - "step_transitions": _event_payloads(events, "skill_step_changed"), - "pending_step_updates": _event_payloads( - events, "graph_pending_steps_updated" - ), - "auto_progress_count": len( - _event_payloads(events, "graph_auto_progress_started") - ), - "llm_phase_order": _llm_phase_order(), - } - ], - ) - - -def _audit_graph_skill(*, parallel: bool) -> dict[str, Any]: - skill_id = "skill_parallel_audit" if parallel else "skill_conditional_audit" - nodes = [ - { - "node_id": "start", - "type": "condition", - "name": "审核入口", - "instruction": "根据审核结果选择后续节点。", - "expected_user_info": ["message_content"], - "allowed_actions": ["continue_flow"], - }, - { - "node_id": "approve", - "type": "response", - "name": "审核通过", - "instruction": "反馈审核通过。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - { - "node_id": "reject", - "type": "response", - "name": "审核拒绝", - "instruction": "反馈审核拒绝。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ] - if parallel: - nodes[1:1] = [ - { - "node_id": "check_payee", - "type": "condition", - "name": "收款方一致性检查", - "instruction": "检查收款方是否一致。", - "expected_user_info": [], - "allowed_actions": ["continue_flow"], - }, - { - "node_id": "check_sensitive", - "type": "condition", - "name": "敏感词检查", - "instruction": "检查敏感词。", - "expected_user_info": [], - "allowed_actions": ["continue_flow"], - }, - { - "node_id": "report", - "type": "response", - "name": "生成报告", - "instruction": "汇总检查结果。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ] - edges = [ - { - "source_node_id": "start", - "next_node_id": "check_payee", - "condition": "报文已获取", - "priority": 0, - }, - { - "source_node_id": "start", - "next_node_id": "check_sensitive", - "condition": "报文已获取", - "priority": 1, - }, - { - "source_node_id": "check_payee", - "next_node_id": "report", - "condition": "一致性检查完成", - "priority": 2, - }, - { - "source_node_id": "check_sensitive", - "next_node_id": "report", - "condition": "敏感词检查完成", - "priority": 3, - }, - ] - terminal_node_ids = ["report"] - else: - edges = [ - { - "source_node_id": "start", - "next_node_id": "approve", - "condition": "审核通过", - "priority": 0, - }, - { - "source_node_id": "start", - "next_node_id": "reject", - "condition": "审核拒绝", - "priority": 1, - }, - ] - terminal_node_ids = ["approve", "reject"] - return { - "skill_id": skill_id, - "version": "1.0.0", - "name": "并行审核" if parallel else "条件审核", - "required_info": ["message_content"], - "nodes": nodes, - "edges": edges, - "start_node_id": "start", - "terminal_node_ids": terminal_node_ids, - } - - -def _event_payloads(events: list[dict[str, Any]], event_type: str) -> list[dict[str, Any]]: - return [item["data"] for item in events if item["event_type"] == event_type] - - -def _llm_phase_order() -> list[str]: - return [ - f"{item['method']}:{item['phase']}" - for item in ScriptedLLMClient.calls() - ] - - -def _scheduled_draft( - harness: GoldenHarness, - monkeypatch: Any, -) -> LegacyScenarioCapture: - from app.api import chat as chat_api - from app.scheduled_tasks.schema import ScheduledTaskDraftRead - - initial = harness.post_sync( - harness.turn_payload("先建立会话。", client_turn_id="client-draft-initial") - ) - draft = ScheduledTaskDraftRead( - should_create=True, - tenant_id="tenant_golden", - agent_id="agent_golden", - title="每日检查价格", - prompt="检查 A1 价格并汇总", - schedule_type="daily", - schedule={"time": "09:00"}, - timezone="Asia/Shanghai", - confidence=1.0, - reason="Golden scripted draft", - source_session_id=initial.session_id, - ) - monkeypatch.setattr( - chat_api, - "detect_scheduled_task_draft", - lambda *_args, **_kwargs: draft, - ) - message = "每天九点检查 A1 价格。" - client_turn_id = "client-draft" - request_extra = { - "session_id": initial.session_id, - "interaction_mode": "scheduled_task", - "client_timezone": "Asia/Shanghai", - } - payload = harness.turn_payload( - message, - client_turn_id=client_turn_id, - session_id=initial.session_id, - ) - payload.update({key: value for key, value in request_extra.items() if key != "session_id"}) - http = harness.post_stream(payload) - - stored_draft = harness.history(initial.session_id)[-1]["metadata"]["scheduled_task_draft"] - create_payload = { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": stored_draft["title"], - "prompt": stored_draft["prompt"], - "description": stored_draft["description"], - "schedule_type": stored_draft["schedule_type"], - "schedule": {"time": "not-a-time"}, - "timezone": stored_draft["timezone"], - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "source_session_id": initial.session_id, - "metadata": {"created_from": "golden_confirmation"}, - } - invalid_status, _ = harness.create_scheduled_task(create_payload) - invalid_history = harness.history(initial.session_id) - invalid_persisted = invalid_history[-1]["metadata"].get("scheduled_task_created") - invalid_task_count = len(harness.list_scheduled_tasks()) - - create_payload["schedule"] = stored_draft["schedule"] - created_status, created = harness.create_scheduled_task(create_payload) - first_refreshed = harness.history(initial.session_id) - first_created_metadata = first_refreshed[-1]["metadata"]["scheduled_task_created"] - first_task_count = len(harness.list_scheduled_tasks()) - duplicate_status, duplicate = harness.create_scheduled_task(create_payload) - refreshed = harness.history(initial.session_id) - duplicate_metadata = refreshed[-1]["metadata"]["scheduled_task_created"] - duplicate_task_count = len(harness.list_scheduled_tasks()) - draft_events = [item for item in http.sse_events if item.event == "scheduled_task_draft"] - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - request_extra=request_extra, - interaction_checks=[ - { - "kind": "scheduled_draft", - "realtime_observation": ( - "transport_event_observed" if len(draft_events) == 1 else "not_observed" - ), - "refresh_observation": ( - "history_payload_observed" - if refreshed[-1]["metadata"].get("scheduled_task_draft") == stored_draft - else "not_observed" - ), - "action_result": { - "evidence_id": "scheduled-draft-invalid-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm_invalid", - "request": {**create_payload, "schedule": {"time": "not-a-time"}}, - "response_status": invalid_status, - "persisted_state": { - "scheduled_task_created": invalid_persisted, - "task_count": invalid_task_count, - }, - }, - }, - { - "kind": "scheduled_draft", - "realtime_observation": ( - "transport_event_observed" if len(draft_events) == 1 else "not_observed" - ), - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "scheduled-draft-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm", - "request": create_payload, - "response_status": created_status, - "persisted_state": { - "created_id_matches_history": created["id"] == first_created_metadata["id"], - "source_session_id": created["source_session_id"], - "title": first_created_metadata["title"], - "schedule": first_created_metadata["schedule"], - "task_count": first_task_count, - }, - }, - }, - { - "kind": "scheduled_draft", - "realtime_observation": ( - "transport_event_observed" if len(draft_events) == 1 else "not_observed" - ), - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "scheduled-draft-duplicate-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm_duplicate", - "request": create_payload, - "response_status": duplicate_status, - "persisted_state": { - "created_distinct_task": duplicate["id"] != created["id"], - "history_points_to_duplicate": duplicate_metadata["id"] == duplicate["id"], - "task_count": duplicate_task_count, - }, - }, - }, - ], - ) - - -def _attachment_history(harness: GoldenHarness) -> LegacyScenarioCapture: - attachments = harness.upload_text_attachment("golden-notes.txt", "第一行\n第二行".encode()) - message = "请总结附件。" - client_turn_id = "client-attachment" - payload = harness.turn_payload(message, client_turn_id=client_turn_id) - payload["attachments"] = attachments - http = harness.post_stream(payload) - stored = harness.history(http.session_id)[0]["metadata"]["attachments"][0] - return LegacyScenarioCapture( - http=http, - message=message, - client_turn_id=client_turn_id, - request_extra={"attachments": attachments}, - interaction_checks=[ - { - "kind": "attachment", - "realtime_observation": "not_observed", - "refresh_observation": ( - "history_payload_observed" if attachments[0] == stored else "not_observed" - ), - "action_result": { - "evidence_id": "attachment-history-inspection", - "evidence_origin": "harness_synthetic", - "resource_id": "golden-notes.txt", - "action": "inspect_after_history", - "request": {"filename": "golden-notes.txt"}, - "response_status": 200, - "persisted_state": {"attachment": stored}, - }, - } - ], - ) - - -def _none_interaction() -> dict[str, Any]: - return { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": None, - } diff --git a/backend/tests/agent_golden/pairwise.py b/backend/tests/agent_golden/pairwise.py deleted file mode 100644 index 5ac582aa..00000000 --- a/backend/tests/agent_golden/pairwise.py +++ /dev/null @@ -1,347 +0,0 @@ -from __future__ import annotations - -import hashlib -import itertools -import json -from collections.abc import Iterable, Mapping, Sequence -from copy import deepcopy -from pathlib import Path -from typing import Any - - -class PairwiseValidationError(ValueError): - pass - - -Assignment = tuple[str, ...] -Pair = tuple[str, str, str, str] - - -def load_json(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as handle: - value = json.load(handle) - if not isinstance(value, dict): - raise PairwiseValidationError(f"expected object at {path}") - return value - - -def profile_dimensions( - manifest: Mapping[str, Any], profile_name: str -) -> dict[str, tuple[str, ...]]: - profile = _profile(manifest, profile_name) - dimensions = {} - for name in manifest["generator"]["dimension_order"]: - values = ( - profile["provider_boundary"] - if name == "provider_boundary" - else manifest["common_dimensions"][name] - ) - dimensions[name] = tuple(values) - return dimensions - - -def enumerate_legal_assignments( - manifest: Mapping[str, Any], profile_name: str = "0A_legacy" -) -> tuple[Assignment, ...]: - dimensions = profile_dimensions(manifest, profile_name) - constraints = _validated_constraints(manifest, profile_name, dimensions) - assignments = itertools.product(*(dimensions[name] for name in dimensions)) - legal = [ - assignment - for assignment in assignments - if _matching_constraints(assignment, dimensions, constraints) - ] - return tuple(sorted(legal)) - - -def legal_pairs(manifest: Mapping[str, Any], profile_name: str = "0A_legacy") -> frozenset[Pair]: - dimensions = profile_dimensions(manifest, profile_name) - return frozenset( - pair - for assignment in enumerate_legal_assignments(manifest, profile_name) - for pair in assignment_pairs(assignment, tuple(dimensions)) - ) - - -def assignment_pairs(assignment: Assignment, dimension_names: Sequence[str]) -> frozenset[Pair]: - return frozenset( - (dimension_names[left], assignment[left], dimension_names[right], assignment[right]) - for left, right in itertools.combinations(range(len(dimension_names)), 2) - ) - - -def coverage_digest(pairs: Iterable[Pair]) -> str: - canonical = json.dumps(sorted(pairs), separators=(",", ":")).encode("utf-8") - return f"sha256:{hashlib.sha256(canonical).hexdigest()}" - - -def generate_cases( - manifest: Mapping[str, Any], profile_name: str = "0A_legacy" -) -> list[dict[str, Any]]: - dimensions = profile_dimensions(manifest, profile_name) - dimension_names = tuple(dimensions) - constraints = _validated_constraints(manifest, profile_name, dimensions) - legal = enumerate_legal_assignments(manifest, profile_name) - if not legal: - raise PairwiseValidationError(f"profile {profile_name!r} has no legal assignments") - - pairs_by_assignment = { - assignment: assignment_pairs(assignment, dimension_names) for assignment in legal - } - uncovered = set().union(*pairs_by_assignment.values()) - selected: list[Assignment] = [] - remaining = set(legal) - - while uncovered: - assignment = min( - remaining, - key=lambda item: (-len(pairs_by_assignment[item] & uncovered), item), - ) - newly_covered = pairs_by_assignment[assignment] & uncovered - if not newly_covered: - raise PairwiseValidationError("generator stalled with uncovered legal pairs") - selected.append(assignment) - remaining.remove(assignment) - uncovered.difference_update(newly_covered) - - selected_set = set(selected) - for constraint in constraints: - candidates = [ - assignment - for assignment in legal - if _constraint_matches(assignment, dimensions, constraint) - ] - if not any(assignment in selected_set for assignment in candidates): - assignment = min(candidates) - selected.append(assignment) - selected_set.add(assignment) - - cases: list[dict[str, Any]] = [] - for index, assignment in enumerate(selected, start=1): - matching = _matching_constraints(assignment, dimensions, constraints) - if len(matching) != 1: - raise PairwiseValidationError( - f"legal assignment must match exactly one constraint: {assignment!r}" - ) - constraint = matching[0] - cases.append( - { - "case_id": f"PW0A-{index:03d}", - "constraint_id": constraint["id"], - "variant_id": constraint["variant_id"], - "entrypoint": constraint["entrypoint"], - "values": dict(zip(dimension_names, assignment, strict=True)), - } - ) - return cases - - -def validate_pairwise_manifest( - manifest: Mapping[str, Any], - catalog: Mapping[str, Any], - profile_name: str = "0A_legacy", -) -> None: - dimensions = profile_dimensions(manifest, profile_name) - dimension_names = tuple(dimensions) - constraints = _validated_constraints(manifest, profile_name, dimensions) - profile = _profile(manifest, profile_name) - catalog_variants = { - variant["variant_id"]: variant - for scenario in catalog["scenarios"] - for variant in scenario["variants"] - } - - _validate_constraint_catalog_links(constraints, catalog_variants) - - cases = profile["cases"] - case_ids = [case.get("case_id") for case in cases] - if len(case_ids) != len(set(case_ids)): - raise PairwiseValidationError("duplicate case_id") - - assignments: list[Assignment] = [] - covered_constraints: set[str] = set() - for case in cases: - values = case.get("values") - if not isinstance(values, Mapping) or set(values) != set(dimension_names): - raise PairwiseValidationError( - f"case {case.get('case_id')!r} must define every dimension exactly once" - ) - for name, value in values.items(): - if value not in dimensions[name]: - raise PairwiseValidationError( - f"case {case.get('case_id')!r} has invalid value {name}={value!r}" - ) - assignment = tuple(values[name] for name in dimension_names) - matching = _matching_constraints(assignment, dimensions, constraints) - if len(matching) != 1: - raise PairwiseValidationError(f"case {case.get('case_id')!r} violates constraints") - constraint = matching[0] - if case.get("constraint_id") != constraint["id"]: - raise PairwiseValidationError(f"case {case.get('case_id')!r} constraint_id mismatch") - if case.get("variant_id") not in catalog_variants: - raise PairwiseValidationError( - f"case {case.get('case_id')!r} references unknown variant" - ) - if case.get("variant_id") != constraint["variant_id"]: - raise PairwiseValidationError( - f"case {case.get('case_id')!r} variant does not match its constraint" - ) - variant = catalog_variants[case["variant_id"]] - if case.get("entrypoint") != variant["entrypoint"]: - raise PairwiseValidationError(f"case {case.get('case_id')!r} entrypoint mismatch") - _validate_case_semantics(case, variant) - assignments.append(assignment) - covered_constraints.add(constraint["id"]) - - if len(assignments) != len(set(assignments)): - raise PairwiseValidationError("duplicate case values") - - if profile["coverage"]["require_each_constraint"]: - missing_constraints = {constraint["id"] for constraint in constraints} - covered_constraints - if missing_constraints: - raise PairwiseValidationError( - f"constraints without cases: {sorted(missing_constraints)!r}" - ) - - required_pairs = legal_pairs(manifest, profile_name) - covered_pairs = frozenset( - pair for assignment in assignments for pair in assignment_pairs(assignment, dimension_names) - ) - missing_pairs = required_pairs - covered_pairs - if missing_pairs: - preview = sorted(missing_pairs)[:5] - raise PairwiseValidationError(f"missing legal pairs ({len(missing_pairs)}): {preview!r}") - - coverage = profile["coverage"] - expected_values = { - "legal_assignment_count": len(enumerate_legal_assignments(manifest, profile_name)), - "legal_pair_count": len(required_pairs), - "covered_pair_count": len(covered_pairs), - "coverage_digest": coverage_digest(required_pairs), - } - for field, expected in expected_values.items(): - if coverage.get(field) != expected: - raise PairwiseValidationError( - f"coverage {field} mismatch: expected {expected!r}, got {coverage.get(field)!r}" - ) - - generated = generate_cases(manifest, profile_name) - if cases != generated: - raise PairwiseValidationError("checked-in cases differ from deterministic generator output") - - -def mutated_manifest(manifest: Mapping[str, Any]) -> dict[str, Any]: - return deepcopy(manifest) - - -def _profile(manifest: Mapping[str, Any], profile_name: str) -> Mapping[str, Any]: - try: - profile = manifest["coverage_profiles"][profile_name] - except (KeyError, TypeError) as exc: - raise PairwiseValidationError(f"unknown profile {profile_name!r}") from exc - if not isinstance(profile, Mapping): - raise PairwiseValidationError(f"profile {profile_name!r} must be an object") - return profile - - -def _validated_constraints( - manifest: Mapping[str, Any], - profile_name: str, - dimensions: Mapping[str, tuple[str, ...]], -) -> tuple[Mapping[str, Any], ...]: - constraints = tuple(_profile(manifest, profile_name)["constraints"]) - ids = [constraint.get("id") for constraint in constraints] - if len(ids) != len(set(ids)): - raise PairwiseValidationError("duplicate constraint id") - for constraint in constraints: - allowed = constraint.get("allowed") - if not isinstance(allowed, Mapping) or set(allowed) != set(dimensions): - raise PairwiseValidationError( - f"constraint {constraint.get('id')!r} must define every dimension" - ) - for name, values in allowed.items(): - if not values: - raise PairwiseValidationError( - f"constraint {constraint.get('id')!r} has no values for {name}" - ) - invalid = set(values) - set(dimensions[name]) - if invalid: - raise PairwiseValidationError( - f"constraint {constraint.get('id')!r} has invalid {name} values: {sorted(invalid)!r}" - ) - - legal_assignments = itertools.product(*(dimensions[name] for name in dimensions)) - for assignment in legal_assignments: - matching = _matching_constraints(assignment, dimensions, constraints) - if len(matching) > 1: - raise PairwiseValidationError( - f"constraints overlap for assignment {assignment!r}: " - f"{[item['id'] for item in matching]!r}" - ) - return constraints - - -def _matching_constraints( - assignment: Assignment, - dimensions: Mapping[str, tuple[str, ...]], - constraints: Sequence[Mapping[str, Any]], -) -> tuple[Mapping[str, Any], ...]: - return tuple( - constraint - for constraint in constraints - if _constraint_matches(assignment, dimensions, constraint) - ) - - -def _constraint_matches( - assignment: Assignment, - dimensions: Mapping[str, tuple[str, ...]], - constraint: Mapping[str, Any], -) -> bool: - return all( - value in constraint["allowed"][name] - for name, value in zip(dimensions, assignment, strict=True) - ) - - -def _validate_constraint_catalog_links( - constraints: Sequence[Mapping[str, Any]], - catalog_variants: Mapping[str, Mapping[str, Any]], -) -> None: - for constraint in constraints: - variant_id = constraint.get("variant_id") - if variant_id not in catalog_variants: - raise PairwiseValidationError( - f"constraint {constraint.get('id')!r} references unknown variant" - ) - if constraint.get("entrypoint") != catalog_variants[variant_id]["entrypoint"]: - raise PairwiseValidationError( - f"constraint {constraint.get('id')!r} entrypoint mismatch" - ) - - -def _validate_case_semantics(case: Mapping[str, Any], variant: Mapping[str, Any]) -> None: - values = case["values"] - expected_entrypoint = "chat_sync" if values["transport"] == "sync" else "chat_sse" - if case["entrypoint"] != expected_entrypoint: - raise PairwiseValidationError(f"case {case.get('case_id')!r} transport/entrypoint mismatch") - provider_applicability = variant["planes"]["provider"]["legacy"] - expected_provider = ( - "not_applicable" if values["provider_boundary"] == "not_applicable" else "required" - ) - if provider_applicability != expected_provider: - raise PairwiseValidationError( - f"case {case.get('case_id')!r} provider applicability mismatch" - ) - allowed_terminals = { - "success": {"completed"}, - "partial": {"waiting"}, - "failure": {"failed"}, - "timeout": {"waiting"}, - "cancel": {"cancelled"}, - } - terminal = variant["contract_v1_expectation"]["terminal"] - if terminal not in allowed_terminals[values["outcome"]]: - raise PairwiseValidationError(f"case {case.get('case_id')!r} outcome/terminal mismatch") - if values["session"] == "refresh" and not variant.get("refresh_action"): - raise PairwiseValidationError(f"case {case.get('case_id')!r} refresh has no catalog action") diff --git a/backend/tests/agent_golden/scripted_dependencies.py b/backend/tests/agent_golden/scripted_dependencies.py deleted file mode 100644 index e8f96085..00000000 --- a/backend/tests/agent_golden/scripted_dependencies.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -import threading -from collections.abc import Iterator -from copy import deepcopy -from dataclasses import dataclass, field -from typing import Any, ClassVar - -from app.llm import LLMError -from app.llm.stage_protocol import STAGE_PROTOCOL_KEY - - -@dataclass -class ScriptedLLMPlan: - reply: str = "这是 Golden 测试的稳定回复。" - stream_chunks: tuple[str, ...] = ("这是 Golden ", "测试的稳定回复。") - fail_phases: set[str] = field(default_factory=set) - json_by_phase: dict[str, dict[str, Any]] = field(default_factory=dict) - json_by_phase_and_message: dict[str, dict[str, dict[str, Any]]] = field( - default_factory=dict - ) - json_sequence_by_phase: dict[str, tuple[dict[str, Any], ...]] = field( - default_factory=dict - ) - - -class ScriptedLLMClient: - """Thread-visible deterministic LLM boundary used by real HTTP workers.""" - - _lock: ClassVar[threading.Lock] = threading.Lock() - _plan: ClassVar[ScriptedLLMPlan] = ScriptedLLMPlan() - _calls: ClassVar[list[dict[str, Any]]] = [] - - def __init__(self, model_config: object) -> None: - self.model_config = model_config - - @classmethod - def configure(cls, plan: ScriptedLLMPlan) -> None: - with cls._lock: - cls._plan = deepcopy(plan) - cls._calls = [] - - @classmethod - def calls(cls) -> list[dict[str, Any]]: - with cls._lock: - return deepcopy(cls._calls) - - def generate_json( - self, _system_prompt: str, payload: dict[str, Any], **_kwargs: Any - ) -> dict[str, Any]: - phase = self._phase(payload) - self._record("json", phase, payload) - self._raise_if_scripted(phase) - by_message = self._plan.json_by_phase_and_message.get(phase, {}) - configured_for_message = by_message.get(str(payload.get("user_message") or "")) - if configured_for_message is not None: - return deepcopy(configured_for_message) - sequence = self._plan.json_sequence_by_phase.get(phase) - if sequence: - phase_call_count = sum( - 1 - for call in self._calls - if call["method"] == "json" and call["phase"] == phase - ) - return deepcopy(sequence[min(phase_call_count - 1, len(sequence) - 1)]) - configured = self._plan.json_by_phase.get(phase) - if configured is not None: - return deepcopy(configured) - if phase == "Router / General Skill Selector": - return { - "use_general_skill": False, - "selected_slug": None, - "use_knowledge": False, - "knowledge_query": None, - "confidence": 1.0, - "reason": "No scripted capability selected.", - } - if phase == "Router": - return { - "decision": "answer_only", - "confidence": 1.0, - "reason": "Scripted plain chat.", - } - if phase == "Step Agent": - return { - "action": "reply", - "reply": self._plan.reply, - "slot_updates": {}, - "is_step_completed": True, - } - if phase == "Reflection": - return {"action": "pass", "needs_retry": False, "reason": "Scripted pass."} - raise AssertionError(f"unhandled scripted JSON phase: {phase!r}") - - def generate_text(self, _system_prompt: str, payload: dict[str, Any], **_kwargs: Any) -> str: - phase = self._phase(payload) - self._record("text", phase, payload) - self._raise_if_scripted(phase) - return self._plan.reply - - def generate_text_stream( - self, - _system_prompt: str, - payload: dict[str, Any], - **_kwargs: Any, - ) -> Iterator[str]: - phase = self._phase(payload) - self._record("stream", phase, payload) - self._raise_if_scripted(phase) - yield from self._plan.stream_chunks - - @classmethod - def _raise_if_scripted(cls, phase: str) -> None: - if phase in cls._plan.fail_phases: - raise LLMError(f"scripted failure at {phase}") - - @classmethod - def _record(cls, method: str, phase: str, payload: dict[str, Any]) -> None: - with cls._lock: - cls._calls.append({"method": method, "phase": phase, "payload": deepcopy(payload)}) - - @staticmethod - def _phase(payload: dict[str, Any]) -> str: - stage = payload.get(STAGE_PROTOCOL_KEY) - if not isinstance(stage, dict): - return "unscoped" - return str(stage.get("phase") or "unscoped") - - -PATCH_TARGETS = ( - "app.api.chat.LLMClient", - "app.core.agent_loop.LLMClient", - "app.core.reflection_agent.LLMClient", - "app.core.response_generator.LLMClient", - "app.core.router.LLMClient", - "app.core.step_agent.LLMClient", - "app.general_skills.runner.LLMClient", - "app.knowledge.service.LLMClient", -) - - -def install_scripted_llm(monkeypatch: Any, plan: ScriptedLLMPlan) -> None: - ScriptedLLMClient.configure(plan) - for target in PATCH_TARGETS: - monkeypatch.setattr(target, ScriptedLLMClient) diff --git a/backend/tests/agent_golden/sse.py b/backend/tests/agent_golden/sse.py deleted file mode 100644 index cbf0eb6e..00000000 --- a/backend/tests/agent_golden/sse.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Iterable -from dataclasses import dataclass -from typing import Any - - -@dataclass(frozen=True) -class SSEEvent: - id: str | None - event: str - data: dict[str, Any] - - -def parse_sse_lines(lines: Iterable[str]) -> list[SSEEvent]: - events: list[SSEEvent] = [] - event_id: str | None = None - event_name = "message" - data_lines: list[str] = [] - - def flush() -> None: - nonlocal event_id, event_name, data_lines - if not data_lines and event_name == "message" and event_id is None: - return - raw_data = "\n".join(data_lines) - decoded = json.loads(raw_data) if raw_data else {} - if not isinstance(decoded, dict): - raise TypeError(f"SSE data must decode to an object: {decoded!r}") - events.append(SSEEvent(id=event_id, event=event_name, data=decoded)) - event_id = None - event_name = "message" - data_lines = [] - - for raw_line in lines: - line = raw_line.rstrip("\r\n") - if not line: - flush() - continue - if line.startswith(":"): - continue - field, separator, value = line.partition(":") - if separator and value.startswith(" "): - value = value[1:] - if field == "id": - event_id = value - elif field == "event": - event_name = value - elif field == "data": - data_lines.append(value) - flush() - return events diff --git a/backend/tests/agent_golden/support.py b/backend/tests/agent_golden/support.py deleted file mode 100644 index 03938e68..00000000 --- a/backend/tests/agent_golden/support.py +++ /dev/null @@ -1,381 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import re -import threading -from collections.abc import Mapping, Sequence -from copy import deepcopy -from datetime import UTC, datetime, timedelta -from itertools import pairwise -from pathlib import Path -from typing import Any, ClassVar - - -class DeterministicIdFactory: - """Allocate stable, thread-safe identifiers while preserving join identity.""" - - def __init__(self) -> None: - self._lock = threading.Lock() - self._counters: dict[str, int] = {} - - def next(self, prefix: str) -> str: - if not prefix or not prefix.replace("-", "_").isalnum(): - raise ValueError(f"invalid deterministic id prefix: {prefix!r}") - with self._lock: - value = self._counters.get(prefix, 0) + 1 - self._counters[prefix] = value - return f"{prefix}_{value:04d}" - - def for_key(self, prefix: str, producer_key: str) -> str: - if not prefix or not prefix.replace("-", "_").isalnum(): - raise ValueError(f"invalid deterministic id prefix: {prefix!r}") - if not producer_key: - raise ValueError("producer_key must not be empty") - digest = hashlib.sha256(f"{prefix}\0{producer_key}".encode()).hexdigest()[:16] - return f"{prefix}_{digest}" - - -class MonotonicClock: - """Return deterministic UTC instants that are strictly increasing.""" - - def __init__( - self, - start: datetime = datetime(2024, 1, 1, tzinfo=UTC), - step: timedelta = timedelta(milliseconds=1), - ) -> None: - if start.tzinfo is None: - raise ValueError("start must be timezone-aware") - if step <= timedelta(0): - raise ValueError("step must be positive") - self._next = start.astimezone(UTC) - self._step = step - self._lock = threading.Lock() - - def now(self) -> datetime: - with self._lock: - current = self._next - self._next += self._step - return current - - def now_iso(self) -> str: - return self.now().isoformat(timespec="milliseconds").replace("+00:00", "Z") - - -class CanonicalNormalizer: - """Normalize volatile scalar values without changing collection semantics.""" - - _VOLATILE_ID_KEYS: ClassVar[set[str]] = { - "session_id", - "sessionId", - "newSessionId", - "message_id", - "user_message_id", - "assistant_message_id", - "turn_id", - "event_id", - "interaction_id", - "run_id", - "handoff_id", - "draft_id", - } - _EXACT_TIME_KEYS: ClassVar[set[str]] = {"created_at", "updated_at", "timestamp"} - - def __init__( - self, - *, - rfc3339_timestamps: bool = False, - rules: Sequence[Mapping[str, Any]] | None = None, - ) -> None: - self._ids: dict[str, str] = {} - self._times: dict[str, str] = {} - self._rfc3339_timestamps = rfc3339_timestamps - self._rules = list(rules) if rules is not None else None - self._source: Any = None - - @classmethod - def from_profile( - cls, - path: Path, - profile_id: str, - *, - rfc3339_timestamps: bool = False, - ) -> CanonicalNormalizer: - document = load_json(path) - profiles = [item for item in document["profiles"] if item["id"] == profile_id] - if len(profiles) != 1: - raise ValueError(f"expected exactly one normalization profile {profile_id!r}") - rules = profiles[0]["rules"] - supported = { - "identity_map", - "monotonic_time_map", - "duration_placeholder", - "traceback_normalized", - "preserve", - } - unsupported = {item["strategy"] for item in rules} - supported - if unsupported: - raise ValueError(f"unsupported normalization strategies: {sorted(unsupported)}") - return cls(rfc3339_timestamps=rfc3339_timestamps, rules=rules) - - def normalize(self, value: Any) -> Any: - self._source = value - self._register_times(value) - return self._normalize(deepcopy(value), key=None, path=()) - - def dumps(self, value: Any) -> str: - normalized = self.normalize(value) - return ( - json.dumps( - normalized, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - + "\n" - ) - - def _normalize(self, value: Any, *, key: str | None, path: tuple[str | int, ...]) -> Any: - if isinstance(value, Mapping): - return { - item_key: self._normalize( - item_value, - key=item_key, - path=(*path, item_key), - ) - for item_key, item_value in value.items() - } - if isinstance(value, list): - return [ - self._normalize(item, key=key, path=(*path, index)) - for index, item in enumerate(value) - ] - if value is None: - return None - strategy = self._strategy(path, key) - if isinstance(value, str): - if strategy == "identity_map": - return self._stable_map(self._ids, value, "id") - if strategy == "monotonic_time_map": - return self._stable_map(self._times, value, "time") - if strategy == "traceback_normalized": - return re.sub( - r' File "[^"]+/(backend/(?:app|tests)/[^"]+)", line \d+', - r' File "/\1", line ', - value, - ) - if strategy == "duration_placeholder" and isinstance(value, (int, float)) and not isinstance(value, bool): - return "" - return value - - def _strategy( - self, path: tuple[str | int, ...], key: str | None - ) -> str | None: - if self._rules is not None: - rendered = self._render_path(path) - return next( - ( - rule["strategy"] - for rule in self._rules - if self._path_matches(rendered, rule["match"]) - and self._qualifier_matches(path, rule.get("qualifier")) - ), - None, - ) - if key is not None: - if key in self._VOLATILE_ID_KEYS or self._is_contextual_id_path(path): - return "identity_map" - if key in self._EXACT_TIME_KEYS or key.endswith("_at"): - return "monotonic_time_map" - if key == "duration_ms": - return "duration_placeholder" - return None - - @staticmethod - def _render_path(path: tuple[str | int, ...]) -> str: - rendered = "$" - for token in path: - rendered += f"[{token}]" if isinstance(token, int) else f".{token}" - return rendered - - @staticmethod - def _path_matches(path: str, pattern: str) -> bool: - expression = re.escape(pattern) - expression = expression.replace(r"\*\*", "__RECURSIVE__") - expression = expression.replace(r"\[\*\]", r"\[\d+\]") - expression = expression.replace(r"\*", r"[^.\[]+") - expression = expression.replace("__RECURSIVE__", ".*") - return re.fullmatch(expression, path) is not None - - def _qualifier_matches( - self, - path: tuple[str | int, ...], - qualifier: Any, - ) -> bool: - if qualifier is None: - return True - levels_up = qualifier["levels_up"] - if levels_up > len(path): - return False - container = self._source - for token in path[:-levels_up]: - container = container[token] - try: - actual = resolve_json_pointer(container, qualifier["relative_pointer"]) - except AssertionError: - return False - return actual == qualifier["equals"] - - @staticmethod - def _is_contextual_id_path(path: tuple[str | int, ...]) -> bool: - if len(path) == 4 and path[0:2] == ("sse", "events") and path[3] == "id": - return isinstance(path[2], int) - if len(path) == 4 and path[0:2] == ("conversation", "messages") and path[3] == "id": - return isinstance(path[2], int) - return path == ("conversation", "session", "id") - - def _register_times(self, value: Any) -> None: - discovered: set[str] = set() - - def visit(item: Any, path: tuple[str | int, ...] = ()) -> None: - if isinstance(item, Mapping): - for child_key, child in item.items(): - visit(child, (*path, child_key)) - return - if isinstance(item, list): - for index, child in enumerate(item): - visit(child, (*path, index)) - return - if ( - isinstance(item, str) - and self._strategy( - path, path[-1] if path and isinstance(path[-1], str) else None - ) - == "monotonic_time_map" - ): - discovered.add(item) - - visit(value) - for timestamp in sorted(discovered): - if timestamp not in self._times: - ordinal = len(self._times) + 1 - if self._rfc3339_timestamps: - normalized = datetime(2000, 1, 1, tzinfo=UTC) + timedelta( - milliseconds=ordinal - ) - self._times[timestamp] = normalized.isoformat( - timespec="milliseconds" - ).replace("+00:00", "Z") - else: - self._times[timestamp] = f"" - - @staticmethod - def _stable_map(mapping: dict[str, str], value: str, label: str) -> str: - existing = mapping.get(value) - if existing is not None: - return existing - normalized = f"<{label}:{len(mapping) + 1:04d}>" - mapping[value] = normalized - return normalized - - -def load_json(path: Path) -> Any: - with path.open(encoding="utf-8") as handle: - return json.load(handle) - - -def resolve_json_pointer(document: Any, pointer: str) -> Any: - if pointer == "": - return document - if not pointer.startswith("/"): - raise ValueError(f"JSON pointer must start with '/': {pointer!r}") - - current = document - for raw_token in pointer[1:].split("/"): - token = raw_token.replace("~1", "/").replace("~0", "~") - if isinstance(current, Mapping): - if token not in current: - raise AssertionError(f"missing JSON pointer token {token!r} in {pointer!r}") - current = current[token] - elif isinstance(current, Sequence) and not isinstance(current, (str, bytes, bytearray)): - if not token.isascii() or not token.isdecimal() or ( - len(token) > 1 and token.startswith("0") - ): - raise AssertionError( - f"invalid array token {token!r} in {pointer!r}" - ) - index = int(token) - try: - current = current[index] - except IndexError as exc: - raise AssertionError( - f"array token {token!r} is out of range in {pointer!r}" - ) from exc - else: - raise TypeError(f"cannot descend through scalar at token {token!r} in {pointer!r}") - return current - - -def assert_json_join( - documents: Mapping[str, Any], - references: Sequence[tuple[str, str]], -) -> Any: - if len(references) < 2: - raise ValueError("a join assertion requires at least two references") - - resolved: list[tuple[str, str, Any]] = [] - for document_name, pointer in references: - if document_name not in documents: - raise AssertionError(f"unknown join document: {document_name!r}") - resolved.append( - (document_name, pointer, resolve_json_pointer(documents[document_name], pointer)) - ) - - expected = resolved[0][2] - mismatches = [item for item in resolved[1:] if item[2] != expected] - if mismatches: - details = ", ".join( - f"{document_name}{pointer}={value!r}" for document_name, pointer, value in resolved - ) - raise AssertionError(f"cross-plane join mismatch: {details}") - return expected - - -def assert_contiguous_order(items: Sequence[Mapping[str, Any]], field: str) -> None: - observed = [item.get(field) for item in items] - expected = list(range(len(items))) - if observed != expected: - raise AssertionError(f"{field} must be contiguous from zero: {observed!r}") - - -def assert_monotonic_timestamps( - items: Sequence[Mapping[str, Any]], - field: str, - *, - allow_equal: bool = True, -) -> None: - parsed: list[datetime] = [] - for item in items: - raw = item.get(field) - if not isinstance(raw, str): - raise TypeError(f"{field} must be an ISO date-time string: {raw!r}") - try: - parsed.append(datetime.fromisoformat(raw)) - except ValueError as exc: - raise AssertionError(f"invalid {field}: {raw!r}") from exc - pairs = pairwise(parsed) - if allow_equal: - valid = all(left <= right for left, right in pairs) - else: - valid = all(left < right for left, right in pairs) - if not valid: - raise AssertionError(f"{field} must be monotonic: {parsed!r}") - - -def assert_duration_bounds(value: Any, *, minimum: float = 0, maximum: float) -> None: - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise TypeError(f"duration must be numeric: {value!r}") - if not minimum <= value <= maximum: - raise AssertionError( - f"duration {value!r} is outside expected range [{minimum!r}, {maximum!r}]" - ) diff --git a/backend/tests/agent_golden/test_contract_assets.py b/backend/tests/agent_golden/test_contract_assets.py deleted file mode 100644 index 243a55d7..00000000 --- a/backend/tests/agent_golden/test_contract_assets.py +++ /dev/null @@ -1,797 +0,0 @@ -from __future__ import annotations - -import hashlib -import importlib.util -from copy import deepcopy -from pathlib import Path -from types import ModuleType -from typing import Any - -import pytest -from jsonschema import Draft202012Validator, FormatChecker -from referencing import Registry, Resource - -from agent_golden.contract_validation import ( - PLANES, - conformance_gate_failures, - expected_fixture_matrix, - manifest_closure_hash, - schema_registry, - validate_fixture, - validate_fixture_ownership, - validate_fixture_relationships, -) -from agent_golden.support import load_json -from agent_golden.update_seed_corpus import check as check_seed_corpus - -REPO_ROOT = Path(__file__).resolve().parents[3] -CONTRACT_ROOT = REPO_ROOT / "contracts" / "agent" / "v1" - - -@pytest.fixture(scope="module") -def manifest() -> dict[str, Any]: - return load_json(CONTRACT_ROOT / "manifest.json") - - -@pytest.fixture(scope="module") -def schema_documents(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: - return {item["id"]: load_json(CONTRACT_ROOT / item["path"]) for item in manifest["schemas"]} - - -def _registry(schemas: dict[str, dict[str, Any]]) -> Registry: - return Registry().with_resources( - (schema_id, Resource.from_contents(schema)) for schema_id, schema in schemas.items() - ) - - -def _load_source_module(path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location("legacy_agent_loop_completion", path) - if spec is None or spec.loader is None: - raise AssertionError(f"cannot load corpus source module: {path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _unique(values: list[str], label: str) -> set[str]: - assert len(values) == len(set(values)), f"duplicate {label}: {values}" - return set(values) - - -def test_every_schema_is_valid_draft_2020_12( - schema_documents: dict[str, dict[str, Any]], -) -> None: - for schema_id, schema in schema_documents.items(): - assert schema.get("$schema") == "https://json-schema.org/draft/2020-12/schema" - assert schema.get("$id") == schema_id - Draft202012Validator.check_schema(schema) - - -def test_relationship_schemas_accept_multisegment_json_pointers( - schema_documents: dict[str, dict[str, Any]], -) -> None: - pointer_schema = schema_documents["fixture-envelope.schema.json"]["$defs"]["planePointer"] - validator = Draft202012Validator(pointer_schema) - assert not list( - validator.iter_errors( - { - "role": "observed_event_order", - "plane": "db_events", - "pointer": "/events/7/observed_row_order", - } - ) - ) - - -def test_every_registered_instance_validates( - manifest: dict[str, Any], - schema_documents: dict[str, dict[str, Any]], -) -> None: - registry = _registry(schema_documents) - for item in manifest["instances"]: - validator = Draft202012Validator( - schema_documents[item["schema"]], - registry=registry, - format_checker=FormatChecker(), - ) - errors = sorted( - validator.iter_errors(load_json(CONTRACT_ROOT / item["path"])), - key=lambda error: list(error.absolute_path), - ) - assert not errors, "\n".join( - f"{item['path']}:{'/'.join(map(str, error.absolute_path))}: {error.message}" - for error in errors - ) - - -def test_manifest_has_no_orphan_or_duplicate_schema_or_instance( - manifest: dict[str, Any], -) -> None: - schema_ids = [item["id"] for item in manifest["schemas"]] - schema_paths = [item["path"] for item in manifest["schemas"]] - instance_paths = [item["path"] for item in manifest["instances"]] - _unique(schema_ids, "schema id") - _unique(schema_paths, "schema path") - _unique(instance_paths, "instance path") - - actual_schema_paths = { - path.relative_to(CONTRACT_ROOT).as_posix() - for path in (CONTRACT_ROOT / "schemas").rglob("*.json") - } - assert set(schema_paths) == actual_schema_paths - - actual_instance_paths = {path.name for path in CONTRACT_ROOT.glob("*.json")} - assert set(instance_paths) == actual_instance_paths - assert {item["schema"] for item in manifest["instances"]} <= set(schema_ids) - - -def test_catalog_ids_and_semantic_references_are_closed() -> None: - catalog = load_json(CONTRACT_ROOT / "scenario-catalog.json") - registry = load_json(CONTRACT_ROOT / "requirement-registry.json") - vocabulary = load_json(CONTRACT_ROOT / "scenario-vocabulary.json") - - scenario_ids = _unique([item["id"] for item in catalog["scenarios"]], "scenario id") - variants = [variant for scenario in catalog["scenarios"] for variant in scenario["variants"]] - variant_ids = _unique([item["variant_id"] for item in variants], "variant id") - fixture_keys = _unique([item["fixture_key"] for item in variants], "fixture key") - assert scenario_ids == {f"GT{index:02d}" for index in range(1, 18)} - assert len(variant_ids) == len(fixture_keys) == 27 - - requirement_ids = _unique( - [item["id"] for item in registry["requirements"]], - "requirement id", - ) - used_requirements = { - requirement for variant in variants for requirement in variant["requirements"] - } - assert used_requirements == requirement_ids - - for term_kind, variant_field in ( - ("pre_states", "pre_state"), - ("refresh_actions", "refresh_action"), - ): - declared = _unique([item["id"] for item in vocabulary[term_kind]], term_kind) - used = {variant[variant_field] for variant in variants} - assert used == declared - - declared_steps = _unique([item["id"] for item in vocabulary["steps"]], "steps") - used_steps = { - step - for variant in variants - for field in ("steps", "legacy_steps", "contract_v1_steps") - for step in variant.get(field, []) - } - assert used_steps == declared_steps - - for variant in variants: - assert set(variant["planes"]) == { - "provider", - "domain", - "sse", - "db_events", - "conversation", - } - - required_variants = { - "GT01": {"GT01-sync", "GT01-sse", "GT01-feedback-refresh-toggle"}, - "GT02": {"GT02-ask-refresh-continue"}, - "GT03": {"GT03-true", "GT03-false"}, - "GT04": {"GT04-merge"}, - "GT05": {"GT05-pending", "GT05-multi-frame"}, - "GT06": {"GT06-retry-success", "GT06-retry-limit"}, - "GT07": {"GT07-citation-refresh"}, - "GT08": {"GT08-query-before-advance"}, - "GT09": {"GT09-read"}, - "GT10": {"GT10-replay", "GT10-unknown"}, - "GT11": {"GT11-standalone", "GT11-sop"}, - "GT12": {"GT12-disconnect", "GT12-cancel"}, - "GT13": {"GT13-llm-error"}, - "GT14": {"GT14-create", "GT14-reply-resume"}, - "GT15": {"GT15-full"}, - "GT16": {"GT16-history"}, - "GT17": {"GT17-channel", "GT17-scheduled"}, - } - assert { - scenario["id"]: {variant["variant_id"] for variant in scenario["variants"]} - for scenario in catalog["scenarios"] - } == required_variants - - -def test_field_ownership_and_compatibility_boundaries_are_explicit() -> None: - ownership = load_json(CONTRACT_ROOT / "field-ownership.json") - compatibility = load_json(CONTRACT_ROOT / "compatibility-matrix.json") - manifest = load_json(CONTRACT_ROOT / "manifest.json") - - assert set(ownership["planes"]) == { - "provider", - "domain", - "sse", - "db_events", - "conversation", - } - for plane, rules in ownership["planes"].items(): - claims = [item["claim"] for item in rules["owns"]] - assert set(claims).isdisjoint(rules["forbidden"]), plane - _unique(claims, f"{plane} owned claim") - owned_paths = [path for item in rules["owns"] for path in item["payload_paths"]] - _unique(owned_paths, f"{plane} owned payload path") - _unique(rules["forbidden"], f"{plane} forbidden field") - - surfaces = {item["surface"]: item for item in compatibility["surfaces"]} - assert surfaces["Provider contracts"]["v1"] == "service_specific_contract_later" - assert surfaces["scenario_frames"]["v1"] == "execution_authority" - assert surfaces["ChatSession scenario state"]["v1"] == "compatibility_projection_only" - assert {item["service"] for item in manifest["deferred_provider_contracts"]} == { - "knowledge", - "scene_skill", - "general_skill", - } - - -def test_legacy_skill_corpus_matches_hash_and_production_seed() -> None: - corpus = load_json(CONTRACT_ROOT / "legacy-skill-corpus.json") - assert corpus["corpus_class"] == "production_seed" - assert check_seed_corpus() == [] - source_modules: dict[Path, ModuleType] = {} - - for entry in corpus["entries"]: - fixture_path = REPO_ROOT / entry["fixture_path"] - digest = hashlib.sha256(fixture_path.read_bytes()).hexdigest() - assert entry["content_hash"] == f"sha256:{digest}" - - source_path = REPO_ROOT / entry["source_path"] - module = source_modules.setdefault(source_path, _load_source_module(source_path)) - source_value = getattr(module, entry["source_symbol"], None) - assert isinstance(source_value, dict), entry["source_symbol"] - assert source_value == load_json(fixture_path) - assert source_value["skill_id"] == entry["skill_id"] - assert source_value["version"] == entry["version"] - - -def test_interaction_blocks_are_message_embedded_agent_content_only( - schema_documents: dict[str, dict[str, Any]], -) -> None: - validator = Draft202012Validator( - schema_documents["interaction-block.schema.json"], format_checker=FormatChecker() - ) - valid_blocks = [ - { - "schema_version": "1", - "interaction_id": "ix-draft", - "kind": "scheduled_draft", - "resource_id": "draft-1", - "state": "pending", - "allowed_actions": ["confirm", "edit"], - "draft_id": "draft-1", - "source_message_id": "msg-1", - "source_turn_id": "msg-1", - "idempotency_key": "draft-1:v1", - "version": 1, - "expires_at": None, - "extensions": {}, - }, - ] - for block in valid_blocks: - assert list(validator.iter_errors(block)) == [] - - invalid_blocks = [ - {**valid_blocks[0], "state": "confirmed", "allowed_actions": ["confirm"]}, - { - "schema_version": "1", - "interaction_id": "ix-citation", - "kind": "citation", - "resource_id": "chunk-1", - "state": "access_revoked", - "allowed_actions": ["resolve_full_text"], - "title": "Policy", - "excerpt": "...", - "extensions": {}, - }, - { - "schema_version": "1", - "interaction_id": "ix-feedback", - "kind": "feedback", - "resource_id": "msg-2", - "state": "active", - "allowed_actions": ["rate_up"], - "message_id": "msg-2", - "current_rating": None, - "extensions": {}, - }, - { - "schema_version": "1", - "interaction_id": "ix-handoff", - "kind": "handoff", - "resource_id": "handoff-1", - "state": "pending", - "allowed_actions": ["reply"], - "handoff_id": "handoff-1", - "session_id": "session-1", - "extensions": {}, - }, - ] - for block in invalid_blocks: - assert list(validator.iter_errors(block)), block - - -def test_phase_scoped_pairwise_gate_and_report_are_honest() -> None: - pairwise = load_json(CONTRACT_ROOT / "pairwise-manifest.json") - report = load_json(CONTRACT_ROOT / "conformance-report-phase0.json") - legacy_profile = pairwise["coverage_profiles"]["0A_legacy"] - provider_profile = pairwise["coverage_profiles"]["provider_contract"] - - assert legacy_profile["required_from_phase"] == "0A" - assert provider_profile["required_from_phase"] == "provider_contract_slice" - assert "fake_remote_contract" not in legacy_profile["provider_boundary"] - assert "fake_remote_contract" in provider_profile["provider_boundary"] - - if not legacy_profile["cases"]: - assert pairwise["status"] == "incomplete" - assert report["gate_decision"] == "no_go" - assert report["pairwise_coverage"]["status"] == "incomplete" - assert "0A_pairwise_cases" in report["missing_assets"] - - assert report["runtime_conformance"] == "not_implemented" - assert report["gate_decision"] == "no_go" - assert any(item["status"] == "not_run" for item in report["frontend_baseline_results"]) - - expected_determinism = { - (variant_id, execution, hash_seed, timezone) - for variant_id in ( - "GT01-sync", - "GT01-sse", - "GT01-feedback-refresh-toggle", - "GT02-ask-refresh-continue", - "GT03-true", - "GT03-false", - "GT04-merge", - "GT13-llm-error", - "GT15-full", - "GT16-history", - ) - for execution, hash_seed, timezone in ( - ("in_process", None, "controlled_utc_clock"), - ("fresh_process", "1", "UTC"), - ("fresh_process", "777", "America/New_York"), - ) - } - actual_determinism = { - (item["scenario"], item["execution"], item["hash_seed"], item["timezone"]) - for item in report["determinism_runs"] - if item["canonical_match"] - } - assert actual_determinism == expected_determinism - - -def test_phase_0_requirement_coverage_excludes_future_phases() -> None: - registry = load_json(CONTRACT_ROOT / "requirement-registry.json") - report = load_json(CONTRACT_ROOT / "conformance-report-phase0.json") - phase_0_ids = {item["id"] for item in registry["requirements"] if item["phase"] == "0A"} - future_ids = {item["id"] for item in registry["requirements"] if item["phase"] != "0A"} - - assert len(phase_0_ids) == report["requirement_coverage"]["required"] == 29 - assert future_ids == {"AGENT-RELAY-RESUME"} - - -def test_fixture_matrix_is_exact_and_existing_files_are_fully_validated() -> None: - manifest = load_json(CONTRACT_ROOT / "manifest.json") - report = load_json(CONTRACT_ROOT / "conformance-report-phase0.json") - expected = expected_fixture_matrix(CONTRACT_ROOT) - schemas, registry = schema_registry(CONTRACT_ROOT, manifest) - ownership = load_json(CONTRACT_ROOT / "field-ownership.json") - actual = set((CONTRACT_ROOT / "fixtures").rglob("*.json")) - missing = set(expected) - actual - orphans = actual - set(expected) - - assert len(expected) == 27 * 2 * 5 - assert not orphans, sorted(orphans) - groups: dict[tuple[str, str, str], dict[str, dict[str, Any]]] = {} - for path in sorted(actual): - envelope = validate_fixture(CONTRACT_ROOT, expected[path], schemas, registry) - validate_fixture_ownership(envelope, ownership) - key = (envelope["fixture_set"], envelope["scenario_id"], envelope["variant_id"]) - groups.setdefault(key, {})[envelope["plane"]] = envelope - relationship_requirements = load_json(CONTRACT_ROOT / "relationship-requirements.json") - for envelopes in groups.values(): - if set(envelopes) == set(PLANES): - validate_fixture_relationships(envelopes, ownership, relationship_requirements) - - if missing: - assert report["gate_decision"] == "no_go" - assert "fixture_envelopes" in report["missing_assets"] - else: - assert "fixture_envelopes" not in report["missing_assets"] - - -def test_fixture_relationship_walker_executes_rules_and_rejects_mutations() -> None: - ownership = load_json(CONTRACT_ROOT / "field-ownership.json") - envelopes = _relationship_fixture_group() - validate_fixture_relationships(envelopes, ownership) - requirements = _relationship_requirements_for_fixture_group() - validate_fixture_relationships(envelopes, ownership, requirements) - - mismatch = deepcopy(envelopes) - mismatch["conversation"]["payload"]["messages"][0]["id"] = "message-other" - with pytest.raises(AssertionError, match="join .* mismatch"): - validate_fixture_relationships(mismatch, ownership) - - unknown_rule = deepcopy(envelopes) - unknown_rule["domain"]["joins"][0]["rule_id"] = "legacy.unknown" - with pytest.raises(AssertionError, match="unknown fixture relationship rule"): - validate_fixture_relationships(unknown_rule, ownership) - - duplicate_reference = deepcopy(envelopes) - duplicate_reference["domain"]["joins"][0]["references"][1] = deepcopy( - duplicate_reference["domain"]["joins"][0]["references"][0] - ) - with pytest.raises(AssertionError, match="duplicate references"): - validate_fixture_relationships(duplicate_reference, ownership) - - outside_scope = deepcopy(envelopes) - outside_scope["domain"]["joins"][0]["references"][0]["pointer"] = "/facts/0" - with pytest.raises(AssertionError, match="outside rule scope"): - validate_fixture_relationships(outside_scope, ownership) - - reversed_order = deepcopy(envelopes) - reversed_order["sse"]["payload"]["events"][1]["sequence"] = -1 - with pytest.raises(AssertionError, match="happens-before .* violated"): - validate_fixture_relationships(reversed_order, ownership) - - self_reference = deepcopy(envelopes) - self_reference["sse"]["happens_before"][0]["after"] = deepcopy( - self_reference["sse"]["happens_before"][0]["before"] - ) - with pytest.raises(AssertionError, match="self-references"): - validate_fixture_relationships(self_reference, ownership) - - wrong_role = deepcopy(envelopes) - wrong_role["domain"]["joins"][0]["references"][0]["role"] = "wrong_role" - with pytest.raises(AssertionError, match="reference roles mismatch"): - validate_fixture_relationships(wrong_role, ownership, requirements) - - wrong_path = deepcopy(envelopes) - wrong_path["domain"]["joins"][0]["references"][0]["pointer"] = "/outcome/message_id" - wrong_path["domain"]["payload"]["outcome"]["message_id"] = "message-1" - with pytest.raises(AssertionError, match="endpoint path mismatch"): - validate_fixture_relationships(wrong_path, ownership, requirements) - - wrong_event_semantics = deepcopy(envelopes) - wrong_event_semantics["sse"]["payload"]["events"][0]["event"] = "status" - with pytest.raises(AssertionError, match="endpoint qualifier mismatch"): - validate_fixture_relationships(wrong_event_semantics, ownership, requirements) - - required_but_missing = { - "reference_profiles": [ - { - "id": "legacy.required-missing", - "kind": "join", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer_pattern": "/request/turn_id", - }, - { - "role": "conversation_message_id", - "plane": "conversation", - "pointer_pattern": "/messages/*/id", - }, - ], - } - ], - "variants": [ - { - "fixture_set": "legacy_characterization", - "variant_id": "GT01-sse", - "relations": [ - { - "kind": "join", - "plane": "domain", - "name": "required-missing-join", - "profile_id": "legacy.required-missing", - } - ], - } - ], - } - with pytest.raises(AssertionError, match="missing required fixture relationships"): - validate_fixture_relationships(envelopes, ownership, required_but_missing) - - no_variant_requirement = {"reference_profiles": [], "variants": []} - with pytest.raises(AssertionError, match="missing relationship requirements for"): - validate_fixture_relationships(envelopes, ownership, no_variant_requirement) - - -def test_field_ownership_rejects_unresolved_forbidden_and_observation_drift() -> None: - ownership = load_json(CONTRACT_ROOT / "field-ownership.json") - envelope = { - "fixture_set": "legacy_characterization", - "plane": "domain", - "applicability": "required", - "payload": { - "request": {"message": "hello"}, - "router_decision": None, - "step_result": None, - "tool_result": None, - "outcome": {"reply": "world"}, - "facts": [], - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent", - }, - } - validate_fixture_ownership(envelope, ownership) - - unresolved = deepcopy(envelope) - del unresolved["payload"]["outcome"] - with pytest.raises(AssertionError, match="turn_outcome.*unresolved payload paths"): - validate_fixture_ownership(unresolved, ownership) - - forbidden = deepcopy(envelope) - forbidden["payload"]["request"]["provider_credentials"] = {"token": "secret"} - with pytest.raises(AssertionError, match="forbidden fields found.*provider_credentials"): - validate_fixture_ownership(forbidden, ownership) - - foreign_conversation_field = deepcopy(envelope) - foreign_conversation_field["payload"]["request"]["interaction_blocks"] = [] - with pytest.raises(AssertionError, match="forbidden fields found.*interaction_blocks"): - validate_fixture_ownership(foreign_conversation_field, ownership) - - provider_alias = { - "fixture_set": "legacy_characterization", - "plane": "provider", - "applicability": "required", - "payload": { - "exchanges": [ - { - "service": "knowledge", - "boundary_id": "knowledge.search", - "source_symbol": "search", - "operation": "search", - "request": {}, - "result": {"session": {"active_skill_id": "skill-1"}}, - "error": None, - } - ] - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent", - }, - } - with pytest.raises(AssertionError, match="forbidden fields found.*active_skill_id"): - validate_fixture_ownership(provider_alias, ownership) - - observation_drift = deepcopy(envelope) - observation_drift["legacy_field_observation"]["interaction_id"] = "present" - with pytest.raises(AssertionError, match="differ from manifest"): - validate_fixture_ownership(observation_drift, ownership) - - -def _relationship_fixture_group() -> dict[str, dict[str, Any]]: - base = { - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "applicability": "required", - "joins": [], - "happens_before": [], - } - envelopes = {plane: {**deepcopy(base), "plane": plane, "payload": {}} for plane in PLANES} - envelopes["provider"]["payload"] = {"exchanges": []} - envelopes["domain"]["payload"] = { - "request": {"turn_id": "message-1"}, - "outcome": {"message_id": "message-1"}, - "facts": [], - } - envelopes["sse"]["payload"] = { - "events": [ - {"sequence": 0, "event": "user_message_received"}, - {"sequence": 1, "event": "complete"}, - ] - } - envelopes["db_events"]["payload"] = { - "events": [ - {"observed_row_order": 0, "payload": {"turn_id": "message-1"}}, - {"observed_row_order": 1, "payload": {"message_id": "assistant-1"}}, - ] - } - envelopes["conversation"]["payload"] = { - "messages": [{"id": "message-1"}, {"id": "assistant-1"}] - } - envelopes["domain"]["joins"] = [ - { - "name": "turn-message", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id", - }, - { - "role": "db_turn_id", - "plane": "db_events", - "pointer": "/events/0/payload/turn_id", - }, - { - "role": "conversation_message_id", - "plane": "conversation", - "pointer": "/messages/0/id", - }, - ], - } - ] - envelopes["sse"]["happens_before"] = [ - { - "name": "first-before-terminal", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "first_event_sequence", - "plane": "sse", - "pointer": "/events/0/sequence", - }, - "after": { - "role": "terminal_event_sequence", - "plane": "sse", - "pointer": "/events/1/sequence", - }, - } - ] - return envelopes - - -def _relationship_requirements_for_fixture_group() -> dict[str, Any]: - return { - "reference_profiles": [ - { - "id": "legacy.test-turn-message", - "kind": "join", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer_pattern": "/request/turn_id", - }, - { - "role": "db_turn_id", - "plane": "db_events", - "pointer_pattern": "/events/*/payload/turn_id", - }, - { - "role": "conversation_message_id", - "plane": "conversation", - "pointer_pattern": "/messages/*/id", - }, - ], - }, - { - "id": "legacy.test-order", - "kind": "happens_before", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "first_event_sequence", - "plane": "sse", - "pointer_pattern": "/events/*/sequence", - "qualifier": { - "levels_up": 1, - "relative_pointer": "/event", - "equals": "user_message_received", - }, - }, - "after": { - "role": "terminal_event_sequence", - "plane": "sse", - "pointer_pattern": "/events/*/sequence", - "qualifier": { - "levels_up": 1, - "relative_pointer": "/event", - "equals": "complete", - }, - }, - }, - ], - "variants": [ - { - "fixture_set": "legacy_characterization", - "variant_id": "GT01-sse", - "relations": [ - { - "kind": "join", - "plane": "domain", - "name": "turn-message", - "profile_id": "legacy.test-turn-message", - }, - { - "kind": "happens_before", - "plane": "sse", - "name": "first-before-terminal", - "profile_id": "legacy.test-order", - }, - ], - } - ], - } - - -def test_conformance_go_is_derived_from_evidence() -> None: - report = load_json(CONTRACT_ROOT / "conformance-report-phase0.json") - failures = conformance_gate_failures(report) - assert failures - assert report["gate_decision"] == "no_go" - - dishonest = {**report, "gate_decision": "go", "gate_reasons": []} - assert set(conformance_gate_failures(dishonest)) == { - *failures, - "gate_decision", - } - - nondeterministic = deepcopy(report) - nondeterministic["determinism_runs"][0]["canonical_match"] = False - assert "determinism_runs" in conformance_gate_failures(nondeterministic) - - manual_not_required = deepcopy(report) - for item in manual_not_required["frontend_baseline_results"]: - item["status"] = "pass" - item["evidence_mode"] = "automated" - manual_item = next( - item - for item in manual_not_required["frontend_baseline_results"] - if item["id"] == "manual_chat_frontend_desktop_browser" - ) - manual_item["evidence_mode"] = "manual_observation" - manual_item["gate_required"] = False - assert "frontend_baseline_results" not in conformance_gate_failures(manual_not_required) - manual_item["gate_required"] = True - assert "frontend_baseline_results" in conformance_gate_failures(manual_not_required) - - complete = deepcopy(report) - complete["legacy_characterization_status"] = "complete" - for field in ( - "schema_validation_results", - "scenario_plane_results", - "join_invariant_results", - "happens_before_results", - "http_sse_harness_results", - "frontend_baseline_results", - ): - for item in complete[field]: - item["status"] = "pass" - item["evidence_mode"] = "automated" - item["gate_required"] = True - for field in ( - "requirement_coverage", - "vocabulary_coverage", - "pairwise_coverage", - "seam_coverage", - "corpus_coverage", - ): - complete[field]["status"] = "complete" - complete[field]["covered"] = complete[field]["required"] - complete[field]["missing"] = [] - complete["missing_assets"] = [] - complete["reviews"] = [ - {"role": "architecture", "status": "approved", "runtime_gate": "go"}, - {"role": "qa", "status": "approved", "runtime_gate": "go"}, - ] - complete["gate_decision"] = "no_go" - complete["gate_reasons"] = ["stale manual decision"] - assert conformance_gate_failures(complete) == ["gate_decision"] - complete["gate_decision"] = "go" - complete["gate_reasons"] = [] - assert conformance_gate_failures(complete) == [] - - -def test_report_artifact_hashes_are_current_and_include_manifest_closure() -> None: - report = load_json(CONTRACT_ROOT / "conformance-report-phase0.json") - for relative_path, expected_hash in report["artifact_hashes"].items(): - if relative_path == "manifest_closure": - continue - digest = hashlib.sha256((CONTRACT_ROOT / relative_path).read_bytes()).hexdigest() - assert expected_hash == f"sha256:{digest}", relative_path - assert report["artifact_hashes"].get("manifest_closure") == manifest_closure_hash(CONTRACT_ROOT) diff --git a/backend/tests/agent_golden/test_fixture_writer.py b/backend/tests/agent_golden/test_fixture_writer.py deleted file mode 100644 index 93a28055..00000000 --- a/backend/tests/agent_golden/test_fixture_writer.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from agent_golden.capture_legacy_fixtures import CAPTURED_VARIANTS -from agent_golden.contract_validation import expected_fixture_matrix -from agent_golden.fixture_writer import capture_legacy_envelopes -from agent_golden.harness import GoldenHarness -from agent_golden.legacy_scenario_capture import plan_for_variant -from agent_golden.support import load_json - -REPO_ROOT = Path(__file__).resolve().parents[3] -CONTRACT_ROOT = REPO_ROOT / "contracts" / "agent" / "v1" - - -@pytest.mark.parametrize("variant_id", CAPTURED_VARIANTS) -def test_runtime_recapture_matches_checked_in_fixtures( - variant_id: str, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - expected = { - item.plane: load_json(item.path) - for item in expected_fixture_matrix(CONTRACT_ROOT).values() - if item.fixture_set == "legacy_characterization" - and item.variant_id == variant_id - } - captured_revisions = { - envelope["source"]["repo_revision"] for envelope in expected.values() - } - assert len(captured_revisions) == 1 - harness = GoldenHarness( - tmp_path / f"recapture-{variant_id}.sqlite3", - monkeypatch, - plan_for_variant(variant_id), - ) - try: - actual = capture_legacy_envelopes( - CONTRACT_ROOT, - harness, - variant_id, - monkeypatch, - captured_revision=captured_revisions.pop(), - ) - finally: - harness.close() - - assert actual == expected - - -@pytest.mark.parametrize( - ("hash_seed", "timezone"), - [("1", "UTC"), ("777", "America/New_York")], -) -@pytest.mark.parametrize("variant_id", CAPTURED_VARIANTS) -def test_recapture_is_stable_in_fresh_processes( - hash_seed: str, - timezone: str, - variant_id: str, -) -> None: - env = { - **os.environ, - "PYTHONHASHSEED": hash_seed, - "PYTHONPATH": str(REPO_ROOT / "backend" / "tests"), - "TZ": timezone, - } - subprocess.run( - [ - sys.executable, - "-m", - "agent_golden.capture_legacy_fixtures", - "--variant", - variant_id, - ], - cwd=REPO_ROOT / "backend", - env=env, - check=True, - capture_output=True, - text=True, - ) diff --git a/backend/tests/agent_golden/test_legacy_http_golden.py b/backend/tests/agent_golden/test_legacy_http_golden.py deleted file mode 100644 index 7f0c6146..00000000 --- a/backend/tests/agent_golden/test_legacy_http_golden.py +++ /dev/null @@ -1,354 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from agent_golden.harness import GoldenHarness -from agent_golden.support import CanonicalNormalizer, assert_json_join - - -def test_gt01_sync_uses_real_http_auth_database_and_history( - golden_harness: GoldenHarness, -) -> None: - capture = golden_harness.post_sync( - golden_harness.turn_payload("你好,请介绍一下自己。", client_turn_id="client-gt01-sync") - ) - - assert capture.status_code == 200 - assert capture.response is not None - assert capture.response["reply"] == "这是 Golden 测试的稳定回复。" - assert capture.session_id - rows = golden_harness.database_rows(capture.session_id) - history = golden_harness.history(capture.session_id) - public_session = golden_harness.public_session(capture.session_id) - - _assert_message_event_joins(rows, "client-gt01-sync") - assert [item["id"] for item in history] == [item["id"] for item in rows["messages"]] - assert [item["role"] for item in history] == ["user", "assistant"] - assert history[-1]["content"] == capture.response["reply"] - assert public_session["id"] == capture.session_id - - -def test_gt01_sse_ids_are_durable_and_terminal_history_is_visible( - golden_harness: GoldenHarness, -) -> None: - capture = golden_harness.post_stream( - golden_harness.turn_payload("你好,请流式回复。", client_turn_id="client-gt01-sse") - ) - - assert capture.status_code == 200 - assert capture.content_type.startswith("text/event-stream") - assert capture.session_id - assert capture.sse_events - assert capture.sse_events[-1].event == "complete" - assert "stream_delta" in [item.event for item in capture.sse_events] - - rows = golden_harness.database_rows(capture.session_id) - history = golden_harness.history(capture.session_id) - _assert_message_event_joins(rows, "client-gt01-sse") - - db_event_ids = {item["event_id"] for item in rows["events"]} - durable_sse_ids = [item.id for item in capture.sse_events if item.id is not None] - assert durable_sse_ids - assert set(durable_sse_ids) <= db_event_ids - assert len(durable_sse_ids) == len(set(durable_sse_ids)) - assert history[-1]["role"] == "assistant" - assert history[-1]["content"] == capture.sse_events[-1].data["reply"] - - -def test_gt01_two_independent_runs_have_identical_canonical_planes( - tmp_path, - monkeypatch, -) -> None: - from agent_golden.scripted_dependencies import ScriptedLLMPlan - - captures: list[str] = [] - for index in range(5): - harness = GoldenHarness( - tmp_path / f"determinism-{index}.sqlite3", - monkeypatch, - ScriptedLLMPlan(), - ) - try: - capture = harness.post_stream( - harness.turn_payload("稳定性测试", client_turn_id="client-determinism") - ) - planes = _capture_planes(harness, capture, "稳定性测试", "client-determinism") - captures.append(CanonicalNormalizer().dumps(planes)) - finally: - harness.close() - - assert len(set(captures)) == 1 - - -def test_gt01_feedback_survives_history_refresh_and_clear_is_durable( - golden_harness: GoldenHarness, -) -> None: - capture = golden_harness.post_sync( - golden_harness.turn_payload("请回复后接受评价。", client_turn_id="client-feedback") - ) - history = golden_harness.history(capture.session_id) - assistant_message_id = history[-1]["id"] - assert history[-1]["feedback_rating"] is None - - status, feedback = golden_harness.set_feedback(assistant_message_id, "up") - assert status == 200 - assert feedback["message_id"] == assistant_message_id - assert feedback["rating"] == "up" - assert golden_harness.history(capture.session_id)[-1]["feedback_rating"] == "up" - - invalid_status, _ = golden_harness.set_feedback(assistant_message_id, "invalid") - assert invalid_status == 422 - assert golden_harness.history(capture.session_id)[-1]["feedback_rating"] == "up" - - clear_status, clear_result = golden_harness.clear_feedback(assistant_message_id) - assert clear_status == 200 - assert clear_result == {"status": "deleted"} - assert golden_harness.history(capture.session_id)[-1]["feedback_rating"] is None - - changed_events = [ - item - for item in golden_harness.database_rows(capture.session_id)["events"] - if item["event_type"] == "message_feedback_changed" - ] - assert [item["payload"]["rating"] for item in changed_events] == ["up", None] - assert all(item["payload"]["message_id"] == assistant_message_id for item in changed_events) - - -def test_gt16_attachment_upload_and_history_preserve_structured_metadata( - golden_harness: GoldenHarness, -) -> None: - attachments = golden_harness.upload_text_attachment( - "golden-notes.txt", "第一行\n第二行".encode() - ) - payload = golden_harness.turn_payload( - "请总结附件。", - client_turn_id="client-attachment", - ) - payload["attachments"] = attachments - capture = golden_harness.post_stream(payload) - - assert capture.sse_events[-1].event == "complete" - history = golden_harness.history(capture.session_id) - stored = history[0]["metadata"]["attachments"][0] - assert stored == attachments[0] - assert stored["filename"] == "golden-notes.txt" - assert stored["kind"] == "text" - assert "第一行" in stored["text"] - - -def test_gt13_llm_error_is_durable_visible_and_legacy_stream_closes_without_complete( - tmp_path, - monkeypatch, -) -> None: - from agent_golden.scripted_dependencies import ScriptedLLMPlan - - harness = GoldenHarness( - tmp_path / "llm-error.sqlite3", - monkeypatch, - ScriptedLLMPlan(fail_phases={"Router"}), - ) - try: - repo_root = Path(__file__).resolve().parents[3] - harness.publish_scene_skill( - json.loads( - (repo_root / "contracts/agent/v1/corpus/production_seed/purchase.json").read_text() - ) - ) - capture = harness.post_stream( - harness.turn_payload("触发模型异常", client_turn_id="client-llm-error") - ) - assert capture.status_code == 200 - assert capture.session_id - event_names = [item.event for item in capture.sse_events] - assert event_names.count("error_occurred") == 1 - assert "complete" not in event_names - assert event_names.index("error_occurred") < event_names.index("stream_end") - assert event_names[-2:] == ["assistant_message_created", "session_state_changed"] - - rows = harness.database_rows(capture.session_id) - errors = [item for item in rows["events"] if item["event_type"] == "error_occurred"] - assert len(errors) == 1 - assert errors[0]["payload"]["code"] == "LLM_ERROR" - assert errors[0]["payload"]["client_turn_id"] == "client-llm-error" - assert "scripted failure at Router" in errors[0]["payload"]["message"] - - history = harness.history(capture.session_id) - assert history[-1]["role"] == "assistant" - streamed_reply = "".join( - str(item.data.get("content") or "") - for item in capture.sse_events - if item.event == "stream_delta" - ) - assert history[-1]["content"] == streamed_reply - assert "模型调用失败" in history[-1]["content"] - finally: - harness.close() - - -def test_gt15_scheduled_draft_matches_realtime_event_and_refreshed_history( - golden_harness: GoldenHarness, - monkeypatch, -) -> None: - from app.api import chat as chat_api - from app.scheduled_tasks.schema import ScheduledTaskDraftRead - - initial = golden_harness.post_sync( - golden_harness.turn_payload("先建立会话。", client_turn_id="client-draft-initial") - ) - draft = ScheduledTaskDraftRead( - should_create=True, - tenant_id="tenant_golden", - agent_id="agent_golden", - title="每日检查价格", - prompt="检查 A1 价格并汇总", - schedule_type="daily", - schedule={"time": "09:00"}, - timezone="Asia/Shanghai", - confidence=1.0, - reason="Golden scripted draft", - source_session_id=initial.session_id, - ) - monkeypatch.setattr(chat_api, "detect_scheduled_task_draft", lambda *_args, **_kwargs: draft) - payload = golden_harness.turn_payload( - "每天九点检查 A1 价格。", - client_turn_id="client-draft", - session_id=initial.session_id, - ) - payload["interaction_mode"] = "scheduled_task" - payload["client_timezone"] = "Asia/Shanghai" - - capture = golden_harness.post_stream(payload) - - assert capture.sse_events[-1].event == "complete" - draft_events = [item for item in capture.sse_events if item.event == "scheduled_task_draft"] - assert len(draft_events) == 1 - realtime_draft = draft_events[0].data - history = golden_harness.history(initial.session_id) - stored_draft = history[-1]["metadata"]["scheduled_task_draft"] - assert stored_draft == draft.model_dump(mode="json") - assert {key: realtime_draft[key] for key in stored_draft} == stored_draft - - rows = golden_harness.database_rows(initial.session_id) - persisted = [ - item for item in rows["events"] if item["event_type"] == "scheduled_task_draft_created" - ] - assert len(persisted) == 1 - assert persisted[0]["payload"]["title"] == stored_draft["title"] - assert history[-1]["turn_id"] == history[-2]["id"] - - create_payload = { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": stored_draft["title"], - "prompt": stored_draft["prompt"], - "description": stored_draft["description"], - "schedule_type": stored_draft["schedule_type"], - "schedule": {"time": "not-a-time"}, - "timezone": stored_draft["timezone"], - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "source_session_id": initial.session_id, - "metadata": {"created_from": "golden_confirmation"}, - } - invalid_status, _ = golden_harness.create_scheduled_task(create_payload) - assert invalid_status == 400 - assert "scheduled_task_created" not in golden_harness.history(initial.session_id)[-1]["metadata"] - - create_payload["schedule"] = stored_draft["schedule"] - created_status, created = golden_harness.create_scheduled_task(create_payload) - assert created_status == 200 - assert created["source_session_id"] == initial.session_id - refreshed = golden_harness.history(initial.session_id) - assert refreshed[-1]["metadata"]["scheduled_task_draft"] == stored_draft - assert refreshed[-1]["metadata"]["scheduled_task_created"]["id"] == created["id"] - - -def _capture_planes( - harness: GoldenHarness, - capture, - message: str, - client_turn_id: str, -) -> dict[str, Any]: - rows = harness.database_rows(capture.session_id) - history = harness.history(capture.session_id) - public_session = harness.public_session(capture.session_id) - terminal = capture.sse_events[-1] if capture.sse_events else None - response = capture.response or (terminal.data if terminal else None) - return { - "domain": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": message, - "client_turn_id": client_turn_id, - }, - "router_decision": response.get("router_decision") if response else None, - "step_result": response.get("step_result") if response else None, - "tool_result": response.get("tool_result") if response else None, - "outcome": {"reply": response.get("reply"), "session_id": capture.session_id}, - "facts": [], - "termination": terminal.event if terminal else "sync_response", - }, - "sse": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": capture.status_code, - "content_type": capture.content_type, - }, - "events": [ - {"sequence": index, "id": item.id, "event": item.event, "data": item.data} - for index, item in enumerate(capture.sse_events) - ], - }, - "db_events": {"events": rows["events"]}, - "conversation": { - "sync_response": capture.response, - "messages": history, - "session": public_session, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": None, - } - ], - }, - } - - -def _assert_message_event_joins(rows: dict[str, Any], client_turn_id: str) -> None: - messages = rows["messages"] - events = rows["events"] - user_index = next( - index for index, item in enumerate(events) if item["event_type"] == "user_message_received" - ) - assistant_index = next( - index - for index, item in enumerate(events) - if item["event_type"] == "assistant_message_created" - ) - documents = {"messages": messages, "events": events} - - assert_json_join( - documents, - [ - ("messages", "/0/id"), - ("events", f"/{user_index}/payload/message_id"), - ("events", f"/{user_index}/payload/turn_id"), - ("events", f"/{user_index}/payload/user_message_id"), - ], - ) - assert_json_join( - documents, - [ - ("messages", "/1/id"), - ("events", f"/{assistant_index}/payload/message_id"), - ], - ) - assert events[user_index]["payload"]["client_turn_id"] == client_turn_id - assert messages[0]["id"] != client_turn_id diff --git a/backend/tests/agent_golden/test_pairwise.py b/backend/tests/agent_golden/test_pairwise.py deleted file mode 100644 index e89619e7..00000000 --- a/backend/tests/agent_golden/test_pairwise.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from agent_golden.pairwise import ( - PairwiseValidationError, - assignment_pairs, - generate_cases, - load_json, - mutated_manifest, - profile_dimensions, - validate_pairwise_manifest, -) - -REPO_ROOT = Path(__file__).resolve().parents[3] -CONTRACT_ROOT = REPO_ROOT / "contracts" / "agent" / "v1" - - -@pytest.fixture(scope="module") -def manifest() -> dict: - return load_json(CONTRACT_ROOT / "pairwise-manifest.json") - - -@pytest.fixture(scope="module") -def catalog() -> dict: - return load_json(CONTRACT_ROOT / "scenario-catalog.json") - - -def test_checked_in_pairwise_cases_are_valid_and_deterministic( - manifest: dict, catalog: dict -) -> None: - validate_pairwise_manifest(manifest, catalog) - assert manifest["coverage_profiles"]["0A_legacy"]["cases"] == generate_cases(manifest) - - -def test_dimension_object_order_does_not_change_generation(manifest: dict) -> None: - reordered = mutated_manifest(manifest) - reordered["common_dimensions"] = dict(reversed(tuple(reordered["common_dimensions"].items()))) - assert generate_cases(reordered) == generate_cases(manifest) - - -def test_removing_case_exposes_a_missing_legal_pair(manifest: dict, catalog: dict) -> None: - dimensions = tuple(profile_dimensions(manifest, "0A_legacy")) - cases = manifest["coverage_profiles"]["0A_legacy"]["cases"] - removable_index = next( - index - for index, case in enumerate(cases) - if assignment_pairs(tuple(case["values"][name] for name in dimensions), dimensions) - - set().union( - *( - assignment_pairs(tuple(other["values"][name] for name in dimensions), dimensions) - for other_index, other in enumerate(cases) - if other_index != index - ) - ) - ) - mutated = mutated_manifest(manifest) - del mutated["coverage_profiles"]["0A_legacy"]["cases"][removable_index] - with pytest.raises(PairwiseValidationError, match="missing legal pairs"): - validate_pairwise_manifest(mutated, catalog) - - -def test_invalid_dimension_value_is_rejected(manifest: dict, catalog: dict) -> None: - mutated = mutated_manifest(manifest) - mutated["coverage_profiles"]["0A_legacy"]["cases"][0]["values"]["transport"] = "websocket" - with pytest.raises(PairwiseValidationError, match="invalid value"): - validate_pairwise_manifest(mutated, catalog) - - -def test_constraint_violating_case_is_rejected(manifest: dict, catalog: dict) -> None: - mutated = mutated_manifest(manifest) - case = mutated["coverage_profiles"]["0A_legacy"]["cases"][0] - case["values"].update( - { - "outcome": "cancel", - "action": "knowledge", - "provider_boundary": "local_raw_exchange", - } - ) - with pytest.raises(PairwiseValidationError, match="violates constraints"): - validate_pairwise_manifest(mutated, catalog) - - -def test_unknown_variant_is_rejected(manifest: dict, catalog: dict) -> None: - mutated = mutated_manifest(manifest) - mutated["coverage_profiles"]["0A_legacy"]["cases"][0]["variant_id"] = "GT99-missing" - with pytest.raises(PairwiseValidationError, match="unknown variant"): - validate_pairwise_manifest(mutated, catalog) - - -def test_entrypoint_mismatch_is_rejected(manifest: dict, catalog: dict) -> None: - mutated = mutated_manifest(manifest) - case = mutated["coverage_profiles"]["0A_legacy"]["cases"][0] - case["entrypoint"] = "chat_sync" if case["entrypoint"] == "chat_sse" else "chat_sse" - with pytest.raises(PairwiseValidationError, match="entrypoint mismatch"): - validate_pairwise_manifest(mutated, catalog) - - -def test_duplicate_case_id_is_rejected(manifest: dict, catalog: dict) -> None: - mutated = mutated_manifest(manifest) - cases = mutated["coverage_profiles"]["0A_legacy"]["cases"] - cases[1]["case_id"] = cases[0]["case_id"] - with pytest.raises(PairwiseValidationError, match="duplicate case_id"): - validate_pairwise_manifest(mutated, catalog) diff --git a/backend/tests/agent_golden/test_real_socket.py b/backend/tests/agent_golden/test_real_socket.py deleted file mode 100644 index 637a57fd..00000000 --- a/backend/tests/agent_golden/test_real_socket.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import annotations - -import json -import socket -import threading -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -from typing import Any - -import httpx -import uvicorn - -from agent_golden.harness import GoldenHarness -from agent_golden.scripted_dependencies import ScriptedLLMPlan -from app.core.agent_loop import AgentLoop - -REPO_ROOT = Path(__file__).resolve().parents[3] - - -def test_gt01_success_terminal_has_committed_history_and_turn_identity( - tmp_path, - monkeypatch, -) -> None: - harness = GoldenHarness( - tmp_path / "success-terminal.sqlite3", - monkeypatch, - ScriptedLLMPlan(stream_chunks=("committed ", "reply")), - ) - original_finalize_turn = AgentLoop._finalize_turn - finalize_staged = threading.Event() - allow_commit = threading.Event() - - def finalize_then_wait_for_history_probe(self, *args, **kwargs): - reply = original_finalize_turn(self, *args, **kwargs) - finalize_staged.set() - if not allow_commit.wait(timeout=8): - raise AssertionError("timed out waiting to release the GT01 commit barrier") - return reply - - monkeypatch.setattr(AgentLoop, "_finalize_turn", finalize_then_wait_for_history_probe) - try: - with ( - _live_server(harness) as base_url, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as history_client, - ): - stream_end_seen = threading.Event() - complete_seen = threading.Event() - state: dict[str, Any] = { - "observed": [], - "session_id": "", - "user_message_id": "", - "complete_count": 0, - } - consumer_errors: list[Exception] = [] - - def consume_stream() -> None: - try: - with ( - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as stream_client, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as terminal_history_client, - stream_client.stream( - "POST", - "/api/chat/stream", - json=harness.turn_payload( - "成功终态可见性测试", - client_turn_id="client-real-success-terminal", - ), - ) as response, - ): - assert response.status_code == 200 - for event in _iter_sse(response): - name = event["event"] - state["observed"].append(name) - state["session_id"] = str( - event["data"].get("sessionId") or state["session_id"] - ) - if name == "user_message_received": - state["user_message_id"] = str(event["data"]["message_id"]) - if name == "stream_end": - stream_end_seen.set() - if name != "complete": - continue - state["complete_count"] += 1 - state["complete"] = event - complete_seen.set() - terminal_history = terminal_history_client.get( - f"/api/chat/sessions/{state['session_id']}/messages", - params={"tenant_id": "tenant_golden"}, - ) - assert terminal_history.status_code == 200, terminal_history.text - state["terminal_history"] = terminal_history.json() - except ( - AssertionError, - KeyError, - RuntimeError, - TypeError, - ValueError, - httpx.HTTPError, - ) as exc: - consumer_errors.append(exc) - complete_seen.set() - - consumer = threading.Thread(target=consume_stream, daemon=True) - consumer.start() - try: - assert finalize_staged.wait(timeout=5), "finalize barrier was not reached" - assert stream_end_seen.wait(timeout=5), "stream_end did not reach the socket" - assert not complete_seen.is_set(), "complete arrived before the commit barrier" - assert state["session_id"] - assert state["user_message_id"] - staged_history = history_client.get( - f"/api/chat/sessions/{state['session_id']}/messages", - params={"tenant_id": "tenant_golden"}, - ) - assert staged_history.status_code == 200, staged_history.text - assert [item["role"] for item in staged_history.json()] == ["user"] - finally: - allow_commit.set() - consumer.join(timeout=10) - - assert not consumer.is_alive(), "SSE consumer did not finish" - if consumer_errors: - raise consumer_errors[0] - assert state["observed"][-1] == "complete" - assert state["complete_count"] == 1 - assert state["complete"]["id"] - terminal_history = state["terminal_history"] - assert [item["role"] for item in terminal_history] == ["user", "assistant"] - assert terminal_history[-1]["content"] == state["complete"]["data"]["reply"] - assert terminal_history[-1]["metadata"]["turn_id"] == state["user_message_id"] - finally: - allow_commit.set() - harness.close() - - -def test_gt12_transport_disconnect_worker_finishes_and_history_recovers( - tmp_path, - monkeypatch, -) -> None: - harness = GoldenHarness( - tmp_path / "disconnect.sqlite3", - monkeypatch, - ScriptedLLMPlan(stream_chunks=tuple(f"chunk-{index} " for index in range(80))), - ) - monkeypatch.setattr(AgentLoop, "_pace_stream", lambda *_args: time.sleep(0.01)) - try: - with ( - _live_server(harness) as base_url, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as client, - ): - session_id = "" - with client.stream( - "POST", - "/api/chat/stream", - json=harness.turn_payload( - "真实连接断开测试", - client_turn_id="client-real-disconnect", - ), - ) as response: - assert response.status_code == 200 - for event in _iter_sse(response): - session_id = str(event["data"].get("sessionId") or session_id) - if event["event"] == "stream_delta": - break - - assert session_id - events = _poll_events(client, session_id, terminal={"complete"}) - assert any(item["event_type"] == "complete" for item in events) - history = client.get( - f"/api/chat/sessions/{session_id}/messages", - params={"tenant_id": "tenant_golden"}, - ) - assert history.status_code == 200 - messages = history.json() - assert [item["role"] for item in messages] == ["user", "assistant"] - assert "chunk-79" in messages[-1]["content"] - finally: - harness.close() - - -def test_gt12_explicit_cancel_is_visible_on_stream_and_history( - tmp_path, - monkeypatch, -) -> None: - harness = GoldenHarness( - tmp_path / "cancel.sqlite3", - monkeypatch, - ScriptedLLMPlan(stream_chunks=tuple(f"chunk-{index} " for index in range(80))), - ) - monkeypatch.setattr(AgentLoop, "_pace_stream", lambda *_args: time.sleep(0.01)) - try: - with ( - _live_server(harness) as base_url, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as stream_client, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as command_client, - ): - observed: list[str] = [] - session_id = "" - turn_id = "" - with stream_client.stream( - "POST", - "/api/chat/stream", - json=harness.turn_payload( - "真实显式取消测试", - client_turn_id="client-real-cancel", - ), - ) as response: - assert response.status_code == 200 - for event in _iter_sse(response): - observed.append(event["event"]) - session_id = str(event["data"].get("sessionId") or session_id) - if event["event"] == "user_message_received": - turn_id = str(event["data"]["message_id"]) - cancel = command_client.post( - f"/api/chat/sessions/{session_id}/cancel", - json={"tenant_id": "tenant_golden", "turn_id": turn_id}, - ) - assert cancel.status_code == 200 - if event["event"] == "stream_cancelled": - break - - assert turn_id - assert "stream_cancelled" in observed - events = _poll_events( - command_client, - session_id, - terminal={"stream_cancelled"}, - ) - assert sum(item["event_type"] == "stream_cancelled" for item in events) == 1 - history = command_client.get( - f"/api/chat/sessions/{session_id}/messages", - params={"tenant_id": "tenant_golden"}, - ).json() - assert history[-1]["role"] == "assistant" - assert history[-1]["content"] == "已停止生成" - finally: - harness.close() - - -def test_gt13_history_visibility_at_error_stream_end_and_assistant_event( - tmp_path, - monkeypatch, -) -> None: - harness = GoldenHarness( - tmp_path / "error-boundary.sqlite3", - monkeypatch, - ScriptedLLMPlan(fail_phases={"Router"}), - ) - purchase = REPO_ROOT / "contracts/agent/v1/corpus/production_seed/purchase.json" - harness.publish_scene_skill(json.loads(purchase.read_text(encoding="utf-8"))) - monkeypatch.setattr(AgentLoop, "_pace_stream", lambda *_args: time.sleep(0.02)) - try: - with ( - _live_server(harness) as base_url, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as stream_client, - httpx.Client( - base_url=base_url, - headers=harness.headers, - timeout=10, - ) as history_client, - ): - visibility: dict[str, list[str]] = {} - observed: list[str] = [] - session_id = "" - with stream_client.stream( - "POST", - "/api/chat/stream", - json=harness.turn_payload( - "真实错误边界测试", - client_turn_id="client-real-error-boundary", - ), - ) as response: - for event in _iter_sse(response): - name = event["event"] - observed.append(name) - session_id = str(event["data"].get("sessionId") or session_id) - if name in { - "error_occurred", - "stream_end", - "assistant_message_created", - }: - history = history_client.get( - f"/api/chat/sessions/{session_id}/messages", - params={"tenant_id": "tenant_golden"}, - ) - assert history.status_code == 200 - visibility[name] = [item["role"] for item in history.json()] - - assert "complete" not in observed - assert visibility["error_occurred"] == ["user"] - assert visibility["stream_end"] == ["user", "assistant"] - assert visibility["assistant_message_created"] == ["user", "assistant"] - finally: - harness.close() - - -@contextmanager -def _live_server(harness: GoldenHarness) -> Iterator[str]: - with socket.socket() as probe: - probe.bind(("127.0.0.1", 0)) - port = probe.getsockname()[1] - server = uvicorn.Server( - uvicorn.Config( - harness.app, - host="127.0.0.1", - port=port, - lifespan="off", - log_level="warning", - ) - ) - thread = threading.Thread(target=server.run, daemon=True) - thread.start() - deadline = time.monotonic() + 5 - while not server.started and thread.is_alive() and time.monotonic() < deadline: - time.sleep(0.01) - if not server.started: - raise RuntimeError("loopback Uvicorn server did not start") - try: - yield f"http://127.0.0.1:{port}" - finally: - server.should_exit = True - thread.join(timeout=5) - if thread.is_alive(): - raise RuntimeError("loopback Uvicorn server did not stop") - - -def _iter_sse(response: httpx.Response) -> Iterator[dict[str, Any]]: - event_name = "message" - event_id = "" - data_lines: list[str] = [] - for line in response.iter_lines(): - if not line: - if data_lines: - data = json.loads("\n".join(data_lines)) - assert isinstance(data, dict) - yield {"id": event_id, "event": event_name, "data": data} - event_name = "message" - event_id = "" - data_lines = [] - continue - field, separator, value = line.partition(":") - if separator and value.startswith(" "): - value = value[1:] - if field == "event": - event_name = value - elif field == "id": - event_id = value - elif field == "data": - data_lines.append(value) - - -def _poll_events( - client: httpx.Client, - session_id: str, - *, - terminal: set[str], -) -> list[dict[str, Any]]: - deadline = time.monotonic() + 8 - events: list[dict[str, Any]] = [] - while time.monotonic() < deadline: - response = client.get( - f"/api/chat/sessions/{session_id}/events", - params={"tenant_id": "tenant_golden"}, - ) - assert response.status_code == 200, response.text - events = response.json() - if any(item["event_type"] in terminal for item in events): - return events - time.sleep(0.05) - raise AssertionError(f"timed out waiting for terminal events {sorted(terminal)}") diff --git a/backend/tests/agent_golden/test_sse.py b/backend/tests/agent_golden/test_sse.py deleted file mode 100644 index 4b95b1c4..00000000 --- a/backend/tests/agent_golden/test_sse.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from agent_golden.sse import parse_sse_lines - - -def test_sse_parser_preserves_ids_order_and_multiline_data() -> None: - events = parse_sse_lines( - [ - "id: evt-1", - "event: status", - 'data: {"phase":', - 'data: "routing"}', - "", - ": heartbeat comment", - "event: complete", - 'data: {"ok":true}', - "", - ] - ) - - assert [(item.id, item.event, item.data) for item in events] == [ - ("evt-1", "status", {"phase": "routing"}), - (None, "complete", {"ok": True}), - ] - - -def test_sse_parser_rejects_non_object_data() -> None: - with pytest.raises(TypeError, match="must decode to an object"): - parse_sse_lines(["data: [1,2]", ""]) diff --git a/backend/tests/agent_golden/test_support.py b/backend/tests/agent_golden/test_support.py deleted file mode 100644 index 045b70b0..00000000 --- a/backend/tests/agent_golden/test_support.py +++ /dev/null @@ -1,277 +0,0 @@ -from concurrent.futures import ThreadPoolExecutor -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import pytest - -from agent_golden.support import ( - CanonicalNormalizer, - DeterministicIdFactory, - MonotonicClock, - assert_contiguous_order, - assert_duration_bounds, - assert_json_join, - assert_monotonic_timestamps, - resolve_json_pointer, -) - - -def test_deterministic_id_factory_is_thread_safe_per_prefix() -> None: - factory = DeterministicIdFactory() - with ThreadPoolExecutor(max_workers=8) as executor: - values = list(executor.map(lambda _: factory.next("event"), range(100))) - - assert len(values) == len(set(values)) == 100 - assert sorted(values) == [f"event_{index:04d}" for index in range(1, 101)] - assert factory.next("message") == "message_0001" - - -def test_deterministic_id_factory_producer_keys_ignore_thread_schedule() -> None: - factory = DeterministicIdFactory() - producer_keys = [f"worker-{index}" for index in range(100)] - with ThreadPoolExecutor(max_workers=8) as executor: - forward = dict( - zip( - producer_keys, - executor.map(lambda key: factory.for_key("event", key), producer_keys), - strict=True, - ) - ) - reverse_keys = list(reversed(producer_keys)) - reverse = dict( - zip( - reverse_keys, - executor.map(lambda key: factory.for_key("event", key), reverse_keys), - strict=True, - ) - ) - assert forward == reverse - - -def test_monotonic_clock_is_strict_and_deterministic() -> None: - clock = MonotonicClock( - datetime(2025, 1, 2, 3, 4, 5, tzinfo=UTC), - timedelta(milliseconds=10), - ) - - assert clock.now_iso() == "2025-01-02T03:04:05.000Z" - assert clock.now_iso() == "2025-01-02T03:04:05.010Z" - - -def test_normalizer_preserves_joins_array_order_nulls_and_duplicates() -> None: - raw = { - "event_id": "evt-random", - "nested": { - "event_id": "evt-random", - "skill_id": "skill_purchase", - "resource_id": "chunk_policy", - "created_at": "2026-07-26T03:35:43.123456Z", - "duration_ms": 27, - "cursor": "opaque:cursor", - "nullable": None, - }, - "items": ["second", "first", "first"], - } - - normalized = CanonicalNormalizer().normalize(raw) - - assert normalized["event_id"] == normalized["nested"]["event_id"] - assert normalized["nested"]["skill_id"] == "skill_purchase" - assert normalized["nested"]["resource_id"] == "chunk_policy" - assert normalized["nested"]["created_at"] == "" - assert normalized["nested"]["duration_ms"] == "" - assert normalized["nested"]["cursor"] == "opaque:cursor" - assert normalized["nested"]["nullable"] is None - assert normalized["items"] == ["second", "first", "first"] - - rfc3339 = CanonicalNormalizer(rfc3339_timestamps=True).normalize(raw) - assert rfc3339["nested"]["created_at"] == "2000-01-01T00:00:00.001Z" - assert datetime.fromisoformat(rfc3339["nested"]["created_at"]) - - -def test_json_pointer_and_cross_plane_join_assertions() -> None: - documents = { - "sse": {"events": [{"id": "evt-1"}]}, - "db": {"events": [{"event_id": "evt-1"}]}, - } - - assert resolve_json_pointer({"a/b": {"~key": 3}}, "/a~1b/~0key") == 3 - with pytest.raises(AssertionError, match="invalid array token"): - resolve_json_pointer({"items": ["last"]}, "/items/-1") - assert ( - assert_json_join( - documents, - [("sse", "/events/0/id"), ("db", "/events/0/event_id")], - ) - == "evt-1" - ) - - documents["db"]["events"][0]["event_id"] = "evt-2" - with pytest.raises(AssertionError, match="cross-plane join mismatch"): - assert_json_join( - documents, - [("sse", "/events/0/id"), ("db", "/events/0/event_id")], - ) - - -def test_declared_normalization_profile_drives_precise_resource_id_paths() -> None: - contract_root = Path(__file__).resolve().parents[3] / "contracts" / "agent" / "v1" - raw = { - "domain": { - "request": { - "session_id": "session-runtime", - "source_session_id": "session-runtime", - "attachments": [{"id": "file-runtime"}], - } - }, - "conversation": { - "messages": [ - { - "id": "message-runtime", - "metadata": {"attachments": [{"id": "file-runtime"}]}, - } - ], - "interaction_checks": [ - { - "kind": "feedback", - "action_result": { - "resource_id": "message-runtime", - "persisted_state": { - "up_response": { - "id": "feedback-runtime", - "message_id": "message-runtime", - } - }, - } - }, - { - "kind": "scheduled_draft", - "action_result": {"resource_id": "scheduled-task-draft"}, - }, - { - "kind": "attachment", - "action_result": {"resource_id": "golden-notes.txt"}, - }, - ], - }, - "unrelated": {"id": "business-stable-id"}, - } - normalized = CanonicalNormalizer.from_profile( - contract_root / "normalization-profiles.json", - "agent-golden-v1", - ).normalize(raw) - - request = normalized["domain"]["request"] - assert request["session_id"] == request["source_session_id"] - assert ( - request["attachments"][0]["id"] - == normalized["conversation"]["messages"][0]["metadata"]["attachments"][0]["id"] - ) - assert normalized["unrelated"]["id"] == "business-stable-id" - action_result = normalized["conversation"]["interaction_checks"][0]["action_result"] - assert action_result["resource_id"] == normalized["conversation"]["messages"][0]["id"] - assert action_result["persisted_state"]["up_response"]["id"] != "feedback-runtime" - interaction_checks = normalized["conversation"]["interaction_checks"] - assert interaction_checks[1]["action_result"]["resource_id"] == "scheduled-task-draft" - assert interaction_checks[2]["action_result"]["resource_id"] == "golden-notes.txt" - - -def test_order_time_and_duration_guards_reject_masked_regressions() -> None: - rows = [ - {"sequence": 0, "created_at": "2025-01-01T00:00:00Z"}, - {"sequence": 1, "created_at": "2025-01-01T00:00:01Z"}, - ] - assert_contiguous_order(rows, "sequence") - assert_monotonic_timestamps(rows, "created_at") - assert_duration_bounds(25, maximum=1000) - - with pytest.raises(AssertionError, match="contiguous"): - assert_contiguous_order([rows[1], rows[0]], "sequence") - with pytest.raises(AssertionError, match="monotonic"): - assert_monotonic_timestamps([rows[1], rows[0]], "created_at") - with pytest.raises(AssertionError, match="outside expected range"): - assert_duration_bounds(-1, maximum=1000) - - -def test_canonical_bytes_change_for_order_null_and_duplicate_mutations() -> None: - baseline = {"items": ["a", None, "b", "b"]} - mutations = [ - {"items": ["b", None, "a", "b"]}, - {"items": ["a", "b", "b"]}, - {"items": ["a", None, "b"]}, - ] - baseline_bytes = CanonicalNormalizer().dumps(baseline) - assert all(CanonicalNormalizer().dumps(item) != baseline_bytes for item in mutations) - - -def test_timestamp_normalization_preserves_order_relations() -> None: - forward = { - "items": [ - {"created_at": "2025-01-01T00:00:00Z"}, - {"created_at": "2025-01-01T00:00:01Z"}, - ] - } - reverse = {"items": list(reversed(forward["items"]))} - assert CanonicalNormalizer().dumps(forward) != CanonicalNormalizer().dumps(reverse) - - -def test_contextual_generated_ids_normalize_but_business_ids_do_not() -> None: - raw = { - "sse": {"events": [{"id": "evt-random"}]}, - "conversation": { - "messages": [{"id": "msg-random", "resource_id": "chunk-stable"}], - "session": {"id": "session-random", "active_skill_id": "skill-stable"}, - }, - } - normalized = CanonicalNormalizer().normalize(raw) - assert normalized["sse"]["events"][0]["id"] == "" - assert normalized["conversation"]["messages"][0]["id"] == "" - assert normalized["conversation"]["session"]["id"] == "" - assert normalized["conversation"]["messages"][0]["resource_id"] == "chunk-stable" - assert normalized["conversation"]["session"]["active_skill_id"] == "skill-stable" - - -def test_legacy_camel_case_runtime_fields_normalize_without_breaking_joins() -> None: - raw = { - "sse": { - "sessionId": "session-random", - "newSessionId": "session-random", - "timestamp": "2026-07-26T03:35:43.123456Z", - }, - "db": { - "session_id": "session-random", - "created_at": "2026-07-26T03:35:43.123456Z", - "skill_id": "skill-stable", - "resource_id": "resource-stable", - "chunk_id": "chunk-stable", - }, - } - - normalized = CanonicalNormalizer().normalize(raw) - - assert normalized["sse"]["sessionId"] == normalized["sse"]["newSessionId"] - assert normalized["sse"]["sessionId"] == normalized["db"]["session_id"] - assert normalized["sse"]["timestamp"] == normalized["db"]["created_at"] - assert normalized["db"]["skill_id"] == "skill-stable" - assert normalized["db"]["resource_id"] == "resource-stable" - assert normalized["db"]["chunk_id"] == "chunk-stable" - - -def test_normalizer_stabilizes_repo_traceback_paths_and_lines() -> None: - normalizer = CanonicalNormalizer( - rules=[{"match": "**.error_traceback", "strategy": "traceback_normalized"}] - ) - - normalized = normalizer.normalize( - { - "error_traceback": ( - 'Traceback:\n File "/tmp/work/backend/app/core/agent_loop.py", ' - 'line 1744, in handle_turn_stream\napp.llm.LLMError: failed\n' - ) - } - ) - - assert normalized["error_traceback"] == ( - 'Traceback:\n File "/backend/app/core/agent_loop.py", ' - 'line , in handle_turn_stream\napp.llm.LLMError: failed\n' - ) diff --git a/backend/tests/agent_golden/update_seed_corpus.py b/backend/tests/agent_golden/update_seed_corpus.py deleted file mode 100644 index cd37e32f..00000000 --- a/backend/tests/agent_golden/update_seed_corpus.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - -from app.db.seed import PRICE_COMPARE_SKILL, PURCHASE_SKILL, REFUND_SKILL - -REPO_ROOT = Path(__file__).resolve().parents[3] -OUTPUT_ROOT = REPO_ROOT / "contracts" / "agent" / "v1" / "corpus" / "production_seed" -SOURCES: dict[str, dict[str, Any]] = { - "purchase": PURCHASE_SKILL, - "price_compare": PRICE_COMPARE_SKILL, - "refund": REFUND_SKILL, -} - - -def rendered_corpus() -> dict[Path, bytes]: - return { - OUTPUT_ROOT / f"{name}.json": ( - json.dumps(content, ensure_ascii=False, indent=2) + "\n" - ).encode("utf-8") - for name, content in SOURCES.items() - } - - -def check() -> list[Path]: - return [ - path - for path, expected in rendered_corpus().items() - if not path.is_file() or path.read_bytes() != expected - ] - - -def write() -> None: - OUTPUT_ROOT.mkdir(parents=True, exist_ok=True) - for path, content in rendered_corpus().items(): - path.write_bytes(content) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - if args.check: - stale = check() - if stale: - for path in stale: - print(path.relative_to(REPO_ROOT)) - return 1 - return 0 - write() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/backend/tests/test_agent_loop_completion.py b/backend/tests/test_agent_loop_completion.py deleted file mode 100644 index 914900d3..00000000 --- a/backend/tests/test_agent_loop_completion.py +++ /dev/null @@ -1,2899 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from app.core.agent_loop import ( - GRAPH_PENDING_STEPS_SLOT, - _KNOWLEDGE_RESULTS_CACHE, - _KNOWLEDGE_STEPS_SEEN, - AgentLoop, -) -from app.core.skill_runtime import SkillRuntime -from app.db.models import AgentEvent, ChatSession, Message, Skill, Tool -from app.session.session_schema import ( - AwaitingInput, - KnowledgeQuery, - PendingTask, - RouterDecision, - StepAgentResult, -) -from app.tools.tool_schema import ToolCall, ToolResult - - -class FakeEvents: - def __init__(self) -> None: - self.records: list[tuple[str, str, str, dict]] = [] - - def record(self, tenant_id: str, session_id: str, event_type: str, payload: dict) -> None: - self.records.append((tenant_id, session_id, event_type, payload)) - - -class FakeDb: - def __init__(self) -> None: - self.commits = 0 - self.rollbacks = 0 - self.refreshed: list[object] = [] - self.added: list[object] = [] - - def add(self, row: object) -> None: - self.added.append(row) - - def commit(self) -> None: - self.commits += 1 - - def rollback(self) -> None: - self.rollbacks += 1 - - def refresh(self, row: object) -> None: - self.refreshed.append(row) - - -class FakeExecResult: - def __init__(self, rows: list[object]) -> None: - self.rows = rows - - def all(self) -> list[object]: - return self.rows - - def first(self) -> object | None: - return self.rows[0] if self.rows else None - - -def test_router_decision_only_hydrates_structured_profile_memory() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession(id="session_test", tenant_id="tenant_demo", slots_json={}) - decision = RouterDecision( - decision="start_new_task", - target_skill_id="purchase", - target_step_id="collect_user_name", - slot_hints={"product_name": "a1", "quantity": 1}, - awaiting_input=AwaitingInput( - skill_id="purchase", - step_id="collect_user_name", - expected_fields=["user_name", "product_id"], - ), - ) - - hydrated = loop._hydrate_router_decision_from_context( - session, - decision, - [_purchase_skill()], - [{"kind": "profile", "content": "hm", "metadata": {"key": "preferred_name"}}], - ) - - assert hydrated["primary"] == {"user_name": "hm"} - assert decision.slot_hints == {"product_name": "a1", "quantity": 1, "user_name": "hm"} - assert decision.awaiting_input is not None - assert decision.awaiting_input.expected_fields == ["product_id"] - - -class FakeMessageDb(FakeDb): - def __init__(self, rows: list[Message]) -> None: - super().__init__() - self.rows = rows - - def exec(self, _statement: object) -> FakeExecResult: - return FakeExecResult(self.rows) - - -class FakeEventDb(FakeDb): - def __init__(self, tool: Tool | None, rows: list[AgentEvent]) -> None: - super().__init__() - self.tool = tool - self.rows = rows - self.exec_calls = 0 - - def exec(self, _statement: object) -> FakeExecResult: - self.exec_calls += 1 - if self.exec_calls == 1: - return FakeExecResult([self.tool] if self.tool else []) - return FakeExecResult(self.rows) - - -class FakeToolExecutor: - def __init__(self, db: FakeDb) -> None: - self.db = db - self.commits_seen_before_execute: int | None = None - - def execute( - self, - tenant_id: str, - tool_call: ToolCall, - active_skill_id: str | None = None, - agent_id: str | None = None, - ) -> ToolResult: - self.commits_seen_before_execute = self.db.commits - return ToolResult(tool_name=tool_call.name, success=True, data={"ok": True}) - - -def test_tool_call_start_event_is_committed_before_external_execute() -> None: - db = FakeDb() - executor = FakeToolExecutor(db) - loop = object.__new__(AgentLoop) - loop.db = db - loop.events = FakeEvents() - loop.tool_executor = executor - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - ) - - result = loop._execute_tool_call( - _request("下单"), - session, - ToolCall(name="product.purchase", arguments={"product_id": "A1"}), - ) - - assert result.success is True - assert executor.commits_seen_before_execute == 1 - assert db.commits == 2 - assert [record[2] for record in loop.events.records] == [ - "tool_call_started", - "tool_call_finished", - ] - - -def test_side_effect_tool_call_reuses_previous_successful_result() -> None: - tool = Tool( - tenant_id="tenant_demo", - name="crm.create_ticket", - display_name="创建工单", - method="POST", - url="http://localhost:8000/api/mock/tickets", - enabled=True, - ) - event = AgentEvent( - id="evt_existing_tool_result", - tenant_id="tenant_demo", - session_id="session_test", - event_type="tool_call_finished", - payload_json={ - "tool_name": "crm.create_ticket", - "success": True, - "data": {"ticket_id": "TCK-1001", "status": "created"}, - "tool_call": { - "name": "crm.create_ticket", - "arguments": { - "customer_id": "C-1", - "subject": "发票开具", - "priority": "normal", - }, - }, - }, - ) - db = FakeEventDb(tool, [event]) - executor = FakeToolExecutor(db) - loop = object.__new__(AgentLoop) - loop.db = db - loop.events = FakeEvents() - loop.tool_executor = executor - session = ChatSession( - id="session_test", tenant_id="tenant_demo", active_skill_id="skill_leave_apply_001" - ) - - result = loop._execute_tool_call( - _request("重试一下,如果办理失败需要提示我"), - session, - ToolCall( - name="crm.create_ticket", - arguments={ - "customer_id": "C-1", - "subject": "发票开具", - "priority": "normal", - }, - ), - tool_call_id="toolcall_retry", - ) - - assert result.success is True - assert result.data["ticket_id"] == "TCK-1001" - assert result.data["idempotent_replay"] is True - assert executor.commits_seen_before_execute is None - assert db.commits == 1 - assert [record[2] for record in loop.events.records] == [ - "tool_call_reused", - "tool_call_finished", - ] - assert loop.events.records[-1][3]["idempotent_replay"] is True - - -def test_post_read_only_tool_does_not_reuse_previous_result() -> None: - tool = Tool( - tenant_id="tenant_demo", - name="order.query", - display_name="查询订单", - method="POST", - url="http://localhost:8000/api/mock/order/query", - config_json={"idempotency": {"enabled": False}}, - enabled=True, - ) - event = AgentEvent( - id="evt_existing_query_result", - tenant_id="tenant_demo", - session_id="session_test", - event_type="tool_call_finished", - payload_json={ - "tool_name": "order.query", - "success": True, - "data": {"order_id": "O-1", "status": "paid"}, - "tool_call": {"name": "order.query", "arguments": {"order_id": "O-1"}}, - }, - ) - db = FakeEventDb(tool, [event]) - executor = FakeToolExecutor(db) - loop = object.__new__(AgentLoop) - loop.db = db - loop.events = FakeEvents() - loop.tool_executor = executor - session = ChatSession(id="session_test", tenant_id="tenant_demo", active_skill_id="refund") - - result = loop._execute_tool_call( - _request("查订单"), - session, - ToolCall(name="order.query", arguments={"order_id": "O-1"}), - ) - - assert result.success is True - assert executor.commits_seen_before_execute == 1 - assert [record[2] for record in loop.events.records] == [ - "tool_call_started", - "tool_call_finished", - ] - - -@pytest.mark.parametrize("compacted_now", [False, True]) -def test_stream_emits_context_status_only_when_compaction_runs(compacted_now: bool) -> None: - db = FakeDb() - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id="agent_demo", - slots_json={}, - skill_stack_json=[], - pending_tasks_json=[], - knowledge_context_json=[], - ) - user_message = Message( - id="msg_user", - tenant_id="tenant_demo", - session_id=session.id, - role="user", - content="你好", - ) - - loop.db = db - loop._uses_harness_v2 = lambda _request: False - loop.events = FakeEvents() - loop.memory = SimpleNamespace(context_memories=lambda *_args, **_kwargs: []) - loop.runtime = SimpleNamespace(apply_decision=lambda *_args, **_kwargs: None) - loop.router = SimpleNamespace( - decide=lambda *_args, **_kwargs: RouterDecision( - decision="answer_only", - user_intent="问候", - reason="普通问候,不需要进入业务流程。", - ) - ) - loop._get_or_create_session = lambda _request: session - loop._append_message = lambda *_args, **_kwargs: user_message - loop._get_request_model = lambda *_args, **_kwargs: _model_config() - loop._list_published_skills = lambda *_args, **_kwargs: [_purchase_skill()] - loop._list_enabled_tools = lambda *_args, **_kwargs: [] - loop._tools_with_general_skills = lambda *_args, **_kwargs: [] - loop._get_persona_prompt = lambda *_args, **_kwargs: None - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._finish_stale_completed_skill = lambda *_args, **_kwargs: None - loop._scene_router_deferred_to_general = lambda *_args, **_kwargs: False - loop._hydrate_router_decision_from_context = lambda *_args, **_kwargs: {} - loop._conversation_context = lambda *_args, **_kwargs: { - "metadata": {"compacted_now": compacted_now} - } - loop._get_active_skill = lambda *_args, **_kwargs: None - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: False - loop._auto_knowledge_step_result = lambda *_args, **_kwargs: StepAgentResult() - loop._generate_reply_stream_segment = lambda *_args, **_kwargs: iter(["收到"]) - loop._finalize_turn = lambda *_args, **_kwargs: None - loop._recent_messages = lambda *_args, **_kwargs: [] - loop._enqueue_memory_capture = lambda *_args, **_kwargs: None - - events = list(loop.handle_turn_stream(_request("你好"))) - names = [event["event"] for event in events] - router_index = names.index("router_decision") - reply_index = names.index("stream_delta") - - preparing_indexes = [ - index - for index, event in enumerate(events) - if event["event"] == "status" and event["data"].get("phase") == "preparing" - ] - if compacted_now: - assert len(preparing_indexes) == 1 - assert names.index("user_message_received") < preparing_indexes[0] < router_index - else: - assert preparing_indexes == [] - assert router_index < reply_index - router_payload = events[router_index]["data"] - assert router_payload["user_intent"] == "问候" - assert router_payload["reason"] == "普通问候,不需要进入业务流程。" - - -def test_stream_disconnect_does_not_persist_stop_event_without_cancel_flag() -> None: - db = FakeDb() - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id="agent_demo", - slots_json={}, - skill_stack_json=[], - pending_tasks_json=[], - knowledge_context_json=[], - ) - user_message = Message( - id="msg_user", - tenant_id="tenant_demo", - session_id=session.id, - role="user", - content="你好", - ) - - loop.db = db - loop._uses_harness_v2 = lambda _request: False - loop.events = FakeEvents() - loop.memory = SimpleNamespace(context_memories=lambda *_args, **_kwargs: []) - loop.runtime = SimpleNamespace(apply_decision=lambda *_args, **_kwargs: None) - loop.router = SimpleNamespace( - decide=lambda *_args, **_kwargs: RouterDecision( - decision="answer_only", - user_intent="问候", - reason="普通问候,不需要进入业务流程。", - ) - ) - loop._get_or_create_session = lambda _request: session - loop._append_message = lambda *_args, **_kwargs: user_message - loop._get_request_model = lambda *_args, **_kwargs: _model_config() - loop._list_published_skills = lambda *_args, **_kwargs: [_purchase_skill()] - loop._list_enabled_tools = lambda *_args, **_kwargs: [] - loop._tools_with_general_skills = lambda *_args, **_kwargs: [] - loop._get_persona_prompt = lambda *_args, **_kwargs: None - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._finish_stale_completed_skill = lambda *_args, **_kwargs: None - loop._scene_router_deferred_to_general = lambda *_args, **_kwargs: False - loop._hydrate_router_decision_from_context = lambda *_args, **_kwargs: {} - loop._conversation_context = lambda *_args, **_kwargs: {} - loop._get_active_skill = lambda *_args, **_kwargs: None - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: False - loop._auto_knowledge_step_result = lambda *_args, **_kwargs: StepAgentResult() - - def disconnected_reply_stream(*_args, **_kwargs): - raise GeneratorExit - yield "" - - loop._generate_reply_stream_segment = disconnected_reply_stream - loop._finalize_turn = lambda *_args, **_kwargs: None - loop._recent_messages = lambda *_args, **_kwargs: [] - loop._enqueue_memory_capture = lambda *_args, **_kwargs: None - - with pytest.raises(GeneratorExit): - list(loop.handle_turn_stream(_request("你好"))) - - assert db.rollbacks == 1 - assert "stream_cancelled" not in [record[2] for record in loop.events.records] - - -def test_stream_text_events_are_persisted_for_refresh_recovery() -> None: - db = FakeDb() - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id="agent_demo", - slots_json={}, - skill_stack_json=[], - pending_tasks_json=[], - knowledge_context_json=[], - ) - - loop.db = db - loop.events = FakeEvents() - - payload = {"turn_id": "msg_user", "user_message_id": "msg_user", "content": "收到"} - event = loop._stream_event("stream_delta", session, payload) - - assert event["event"] == "stream_delta" - assert loop.events.records == [ - ("tenant_demo", "session_test", "stream_delta", payload), - ] - assert db.commits == 1 - - -def test_stream_trace_events_require_turn_id_for_persistence() -> None: - db = FakeDb() - loop = object.__new__(AgentLoop) - loop.db = db - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id="agent_demo", - ) - - without_turn = {"toolName": "weather", "success": True} - with_turn = { - "turn_id": "msg_user", - "user_message_id": "msg_user", - "toolName": "weather", - "success": True, - } - - loop._stream_event("tool_result", session, without_turn) - event = loop._stream_event("tool_result", session, with_turn) - - assert event["event"] == "tool_result" - assert loop.events.records == [ - ("tenant_demo", "session_test", "tool_result", with_turn), - ] - assert db.commits == 1 - - -def test_router_order_keeps_current_turn_followup_out_of_pending_tasks() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={"user_name": "hm"}, - ) - router_decision = RouterDecision( - decision="continue_active", - target_skill_id="purchase", - target_step_id="collect_user_name", - confidence=0.91, - user_intent="继续购买 A1,并比较 A1 和 A3", - reason="用户补充购买目标,同时提出独立比价任务。", - source_message="我买 A1 前跟 A3 比一下价格", - slot_hints={"product_id": "A1", "quantity": 1}, - task_frames=[ - PendingTask( - decision="continue_active", - target_skill_id="purchase", - target_step_id="collect_user_name", - user_intent="继续购买 A1", - source_message="我买 A1 前跟 A3 比一下价格", - slot_hints={"product_id": "A1", "quantity": 1}, - ), - PendingTask( - task_id="task_price_compare_a1_a3", - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - user_intent="比较 A1 和 A3 的价格", - source_message="我买 A1 前跟 A3 比一下价格", - slot_hints={"product_name_1": "A1", "product_name_2": "A3"}, - ) - ], - ) - - loop.runtime.apply_decision(session, router_decision) - - assert session.active_skill_id == "purchase" - assert session.active_step_id == "collect_user_name" - assert session.slots_json == {"user_name": "hm", "product_id": "A1", "quantity": 1} - assert session.pending_tasks_json == [] - assert [task.target_skill_id for task in router_decision.task_frames] == [ - "purchase", - "price_compare", - ] - - -def test_router_keeps_existing_active_task_in_current_turn_plan_after_new_primary() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={"user_name": "hm"}, - ) - router_decision = RouterDecision( - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - confidence=0.95, - user_intent="比较 A1 和 A3 的价格", - reason="用户提出独立比价任务。", - source_message="我想买一个A1,然后想跟A3比下价格", - slot_hints={"product_name_1": "A1", "product_name_2": "A3"}, - task_frames=[ - PendingTask( - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - user_intent="比较 A1 和 A3 的价格", - source_message="我想买一个A1,然后想跟A3比下价格", - slot_hints={"product_name_1": "A1", "product_name_2": "A3"}, - ), - PendingTask( - task_id="task_purchase_a1", - decision="continue_active", - target_skill_id="purchase", - target_step_id="collect_user_name", - user_intent="继续购买 A1", - source_message="我想买一个A1,然后想跟A3比下价格", - slot_hints={"user_name": "hm"}, - ) - ], - ) - - loop.runtime.apply_decision(session, router_decision) - - assert session.active_skill_id == "price_compare" - assert session.active_step_id == "collect_products" - assert session.slots_json == {"product_name_1": "A1", "product_name_2": "A3"} - assert session.pending_tasks_json == [] - assert [task.target_skill_id for task in router_decision.task_frames] == [ - "price_compare", - "purchase", - ] - - -def test_current_turn_task_frames_execute_in_order_without_pending_queue() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - loop._get_agent_loop_max_actions = lambda _tenant_id: 4 - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: True - loop._get_reflection_max_rounds = lambda _tenant_id: 0 - loop._run_reflection_rounds = lambda *args, **_kwargs: tuple(args[5:9]) - loop._auto_progress_skill_graph = lambda *args, **_kwargs: tuple(args[5:9]) - loop._generate_reply_segment = lambda *_args, **_kwargs: "已完成" - - skills = [_price_compare_skill(), _purchase_skill()] - skills_by_id = {skill.skill_id: skill for skill in skills} - executed: list[str] = [] - loop._get_active_skill = ( - lambda _tenant_id, skill_id, _agent_id: skills_by_id.get(skill_id or "") - ) - - def run_step(_request, session, active_skill, *_args, **_kwargs): - executed.append(active_skill.skill_id) - return StepAgentResult(reply="已完成", is_step_completed=True) - - def finalize(_tenant_id, session, active_skill, *_args, **_kwargs): - loop.runtime.complete_current_skill(session) - return "completed" - - loop._run_step_agent_with_context_repair = run_step - loop._finalize_execution_after_reply = finalize - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - pending_tasks_json=[], - ) - frames = [ - PendingTask( - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - slot_hints={"product_name_1": "A1", "product_name_2": "A3"}, - ), - PendingTask( - decision="start_new_task", - target_skill_id="purchase", - target_step_id="collect_user_name", - slot_hints={"product_id": "A3", "quantity": 1}, - ), - ] - - result = loop._try_continue_pending_after_completion( - _request("先比较 A1 和 A3,再购买 A3"), - session, - _model_config(), - skills, - [], - None, - [], - {}, - "", - turn_task_frames=frames, - ) - - assert executed == ["price_compare", "purchase"] - assert session.pending_tasks_json == [] - assert result is not None - assert result.reply == "已完成\n\n已完成" - - -def test_non_stream_followup_executes_knowledge_query_before_tool() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - loop._get_agent_loop_max_actions = lambda _tenant_id: 2 - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: True - loop._get_reflection_max_rounds = lambda _tenant_id: 0 - loop._run_reflection_rounds = lambda *args, **_kwargs: tuple(args[5:9]) - loop._auto_progress_skill_graph = lambda *args, **_kwargs: tuple(args[5:9]) - loop._generate_reply_segment = lambda *_args, **_kwargs: "已完成" - skill = _leave_policy_skill() - loop._get_active_skill = lambda *_args, **_kwargs: skill - calls: list[str] = [] - loop._run_step_agent_with_context_repair = lambda *_args, **_kwargs: StepAgentResult( - knowledge_query=KnowledgeQuery(query="事假政策") - ) - - def execute_knowledge(*_args, **_kwargs): - calls.append("knowledge") - return StepAgentResult( - tool_call=ToolCall(name="hr.balance_query", arguments={"employee_id": "E1"}) - ) - - def execute_tool(*args, **_kwargs): - calls.append("tool") - return args[5], ToolResult(tool_name="hr.balance_query", success=True, data={}) - - loop._execute_knowledge_query_cycle = execute_knowledge - loop._execute_tool_action_cycle = execute_tool - loop._finalize_execution_after_reply = lambda *_args, **_kwargs: "completed" - session = ChatSession(id="session_test", tenant_id="tenant_demo", pending_tasks_json=[]) - - result = loop._try_continue_pending_after_completion( - _request("继续请假"), - session, - _model_config(), - [skill], - [], - None, - [], - {}, - "", - turn_task_frames=[ - PendingTask( - decision="start_new_task", - target_skill_id="leave", - target_step_id="check_policy", - slot_hints={"leave_type": "事假"}, - ) - ], - ) - - assert result is not None - assert calls == ["knowledge", "tool"] - - -def test_stream_followup_executes_knowledge_query_before_tool() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - loop._get_agent_loop_max_actions = lambda _tenant_id: 2 - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: True - loop._get_reflection_max_rounds = lambda _tenant_id: 0 - loop._run_reflection_rounds = lambda *args, **_kwargs: tuple(args[5:9]) - loop._auto_progress_skill_graph = lambda *args, **_kwargs: tuple(args[5:9]) - loop._skill_state_payload = lambda *_args, **_kwargs: {} - loop._runtime_stream_context = lambda *_args, **_kwargs: {} - skill = _leave_policy_skill() - loop._get_active_skill = lambda *_args, **_kwargs: skill - loop._run_step_agent_with_context_repair = lambda *_args, **_kwargs: StepAgentResult( - knowledge_query=KnowledgeQuery(query="事假政策") - ) - calls: list[str] = [] - - def execute_knowledge(*_args, **_kwargs): - calls.append("knowledge") - return StepAgentResult( - tool_call=ToolCall(name="hr.balance_query", arguments={"employee_id": "E1"}) - ) - - def execute_tool(*args, **_kwargs): - calls.append("tool") - return args[5], ToolResult(tool_name="hr.balance_query", success=True, data={}) - - loop._execute_knowledge_query_cycle = execute_knowledge - loop._execute_tool_action_cycle = execute_tool - loop._finalize_execution_after_reply = lambda *_args, **_kwargs: "completed" - session = ChatSession(id="session_test", tenant_id="tenant_demo", pending_tasks_json=[]) - iterator = loop._stream_continue_pending_after_completion( - _request("继续请假"), - session, - _model_config(), - [skill], - [], - None, - [], - {}, - "", - user_message_id="msg_user", - turn_task_frames=[ - PendingTask( - decision="start_new_task", - target_skill_id="leave", - target_step_id="check_policy", - slot_hints={"leave_type": "事假"}, - ) - ], - ) - list(iterator) - - assert calls == ["knowledge", "tool"] - - -def test_only_started_waiting_task_becomes_pending_while_later_turn_frame_still_runs() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - loop._get_agent_loop_max_actions = lambda _tenant_id: 4 - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: True - loop._get_reflection_max_rounds = lambda _tenant_id: 0 - loop._run_reflection_rounds = lambda *args, **_kwargs: tuple(args[5:9]) - loop._auto_progress_skill_graph = lambda *args, **_kwargs: tuple(args[5:9]) - skills = [_price_compare_skill(), _purchase_skill()] - skills_by_id = {skill.skill_id: skill for skill in skills} - executed: list[str] = [] - loop._get_active_skill = ( - lambda _tenant_id, skill_id, _agent_id: skills_by_id.get(skill_id or "") - ) - - def run_step(_request, session, active_skill, *_args, **_kwargs): - executed.append(active_skill.skill_id) - if active_skill.skill_id == "price_compare": - session.awaiting_input_json = {"expected_fields": ["product_name_2"]} - return StepAgentResult(reply="请补充第二个商品") - return StepAgentResult(reply="购买完成", is_step_completed=True) - - def finalize(_tenant_id, session, active_skill, *_args, **_kwargs): - if active_skill.skill_id == "price_compare": - return "continued" - loop.runtime.complete_current_skill(session) - return "completed" - - loop._run_step_agent_with_context_repair = run_step - loop._finalize_execution_after_reply = finalize - session = ChatSession(id="session_test", tenant_id="tenant_demo", pending_tasks_json=[]) - frames = [ - PendingTask( - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - slot_hints={"product_name_1": "A1"}, - ), - PendingTask( - decision="start_new_task", - target_skill_id="purchase", - target_step_id="collect_user_name", - slot_hints={"product_id": "A3", "quantity": 1}, - ), - ] - - result = loop._try_continue_pending_after_completion( - _request("先比较 A1 和另一个商品,再购买 A3"), - session, - _model_config(), - skills, - [], - None, - [], - {}, - "", - turn_task_frames=frames, - ) - - assert executed == ["price_compare", "purchase"] - assert [frame["skill_id"] for frame in session.pending_tasks_json] == ["price_compare"] - assert session.pending_tasks_json[0]["awaiting_input"] == { - "expected_fields": ["product_name_2"] - } - assert result is not None - assert result.reply == "请补充第二个商品\n\n购买完成" - - -def test_streamed_followup_tasks_collect_results_without_emitting_replies() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - loop._get_agent_loop_max_actions = lambda _tenant_id: 4 - loop._drop_unavailable_skill_state = lambda *_args, **_kwargs: False - loop._should_record_runtime_event_after_prune = lambda *_args, **_kwargs: False - loop._should_run_step_agent = lambda *_args, **_kwargs: True - loop._get_reflection_max_rounds = lambda _tenant_id: 0 - loop._run_reflection_rounds = lambda *args, **_kwargs: tuple(args[5:9]) - loop._auto_progress_skill_graph = lambda *args, **_kwargs: tuple(args[5:9]) - loop._skill_state_payload = lambda *_args, **_kwargs: {} - loop._runtime_stream_context = lambda *_args, **_kwargs: {} - - skills = [_price_compare_skill(), _purchase_skill()] - skills_by_id = {skill.skill_id: skill for skill in skills} - loop._get_active_skill = ( - lambda _tenant_id, skill_id, _agent_id: skills_by_id.get(skill_id or "") - ) - - def run_step(_request, _session, active_skill, *_args, **_kwargs): - return StepAgentResult( - action="ask_user", - reply=f"{active_skill.name}需要补充信息", - ) - - loop._run_step_agent_with_context_repair = run_step - loop._finalize_execution_after_reply = lambda *_args, **_kwargs: "continued" - session = ChatSession(id="session_test", tenant_id="tenant_demo", pending_tasks_json=[]) - frames = [ - PendingTask( - decision="start_new_task", - target_skill_id="price_compare", - target_step_id="collect_products", - ), - PendingTask( - decision="start_new_task", - target_skill_id="purchase", - target_step_id="collect_user_name", - ), - ] - - iterator = loop._stream_continue_pending_after_completion( - _request("先比价,再购买"), - session, - _model_config(), - skills, - [], - None, - [], - {}, - "", - user_message_id="msg_user", - turn_task_frames=frames, - ) - events: list[dict[str, object]] = [] - while True: - try: - events.append(next(iterator)) - except StopIteration as stop: - result = stop.value - break - - assert result is not None - assert len(result.task_results) == 2 - assert [event["event"] for event in events].count("step_result") == 2 - assert not {"stream_delta", "stream_replace"}.intersection( - event["event"] for event in events - ) - - -def test_drop_unavailable_skill_state_removes_disabled_sop_frames() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="archived_sop", - active_step_id="collect_info", - slots_json={"field": "value"}, - awaiting_input_json={"skill_id": "archived_sop", "step_id": "collect_info"}, - pending_tasks_json=[ - {"task_id": "task_archived", "target_skill_id": "archived_sop"}, - {"task_id": "task_purchase", "target_skill_id": "purchase"}, - ], - skill_stack_json=[ - {"task_id": "stack_archived", "skill_id": "archived_sop"}, - {"task_id": "stack_purchase", "skill_id": "purchase"}, - ], - ) - - changed = loop._drop_unavailable_skill_state("tenant_demo", session, [_purchase_skill()]) - - assert changed is True - assert session.active_skill_id is None - assert session.active_step_id is None - assert session.slots_json == {} - assert session.awaiting_input_json is None - assert session.pending_tasks_json == [ - {"task_id": "task_purchase", "target_skill_id": "purchase"} - ] - assert session.skill_stack_json == [] - assert loop.events.records[-1][2] == "skill_state_pruned" - assert loop.events.records[-1][3]["removed_skill_ids"] == ["archived_sop"] - - -def test_drop_unavailable_skill_state_repairs_removed_active_step() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = _purchase_skill() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="removed_confirmation_step", - slots_json={"product_id": "A1"}, - awaiting_input_json={ - "task_id": "task_purchase", - "skill_id": skill.skill_id, - "step_id": "removed_confirmation_step", - "expected_fields": ["confirmation"], - }, - last_agent_question="请确认", - ) - - changed = loop._drop_unavailable_skill_state("tenant_demo", session, [skill]) - - assert changed is True - assert session.active_skill_id == skill.skill_id - assert session.active_step_id == "collect_user_name" - assert session.slots_json == {"product_id": "A1"} - assert session.awaiting_input_json == {"task_id": "task_purchase"} - assert session.last_agent_question is None - assert loop.events.records[-1][3]["repaired_steps"] == [ - { - "skill_id": skill.skill_id, - "from_step_id": "removed_confirmation_step", - "to_step_id": "collect_user_name", - } - ] - - -def test_skill_state_payload_filters_disabled_sop_frames() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="archived_sop", - active_step_id="collect_info", - pending_tasks_json=[ - { - "task_id": "task_archived", - "target_skill_id": "archived_sop", - "target_step_id": "collect_info", - }, - { - "task_id": "task_purchase", - "target_skill_id": "purchase", - "target_step_id": "collect_user_name", - }, - ], - skill_stack_json=[ - {"task_id": "stack_archived", "skill_id": "archived_sop", "step_id": "collect_info"}, - {"task_id": "stack_purchase", "skill_id": "purchase", "step_id": "confirm_product"}, - ], - ) - - payload = loop._skill_state_payload( - session, - [_purchase_skill()], - user_message_id="msg_current_turn", - ) - - assert payload["activeSkillId"] is None - assert payload["activeStepId"] is None - assert payload["user_message_id"] == "msg_current_turn" - assert payload["turn_id"] == "msg_current_turn" - assert payload["currentSkills"] == [ - { - "skillId": "purchase", - "name": "购买商品", - "stepId": "collect_user_name", - "state": "pending", - }, - ] - - -def test_pruned_disabled_sop_runtime_event_is_not_recorded() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession(id="session_test", tenant_id="tenant_demo") - decision = RouterDecision( - decision="switch_to_pending", - target_skill_id="archived_sop", - target_step_id="collect_info", - ) - - assert ( - loop._should_record_runtime_event_after_prune( - decision, - session, - [_purchase_skill()], - state_pruned=True, - ) - is False - ) - - -def test_finalize_turn_clears_stale_last_question_for_non_question_reply() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - last_agent_question="旧的比价回复。请问您是否决定购买 A1?", - ) - reply = "好的,已为您确认退款申请。正在为您处理订单 MOCKD57272DB0E 的退款,请您耐心等待。" - - loop._finalize_turn(session, "tenant_demo", reply) - - assert session.last_agent_question == "旧的比价回复。请问您是否决定购买 A1?" - assert session.summary == f"最近回复:{reply[:120]}" - assert loop.events.records[0][2] == "assistant_message_created" - - -def test_finalize_turn_keeps_current_question_reply() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - reply = "请提供您的订单号?" - - loop._finalize_turn(session, "tenant_demo", reply) - - assert session.last_agent_question is None - assert session.summary == f"最近回复:{reply[:120]}" - - -def test_finalize_turn_drops_unused_knowledge_citations() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - knowledge_context_json=[ - { - "source_message": "自动任务需要结合业务资料", - "evidence_pack": [ - { - "source_path": "service-handbook.md / 服务原则 / evidence 1", - "excerpt": "服务人员应先确认用户真实诉求。", - } - ], - } - ], - ) - reply = "本次自动任务执行完毕,已成功购买 1 个 A1 商品。" - - loop._finalize_turn(session, "tenant_demo", reply, source_message="自动任务需要结合业务资料") - - message = loop.db.added[-1] - assert isinstance(message, Message) - assert message.content == reply - assert message.metadata_json == {} - assert "knowledge_citations" not in loop.events.records[0][3] - - -def test_finalize_turn_keeps_only_inline_knowledge_citations() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - step_result = StepAgentResult( - knowledge_results=[ - { - "query": {"query": "前端规范有哪些?"}, - "evidence_pack": [ - { - "source_path": "frontend.md / 目录规范 / evidence 1", - "excerpt": "前端目录规范说明。", - }, - { - "source_path": "frontend.md / 命名规范 / evidence 1", - "excerpt": "前端命名规范说明。", - }, - ], - } - ], - ) - reply = "前端规范包括目录组织和命名规范。[2]\n\n参考资料:[1][2]" - - loop._finalize_turn( - session, - "tenant_demo", - reply, - step_result=step_result, - source_message="前端规范有哪些?", - ) - - message = loop.db.added[-1] - assert isinstance(message, Message) - assert message.content == "前端规范包括目录组织和命名规范。[1]" - assert [item["label"] for item in message.metadata_json["knowledge_citations"]] == ["[1]"] - - -def test_finalize_turn_restores_unique_truncated_email_in_response_and_message() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - step_result = StepAgentResult( - knowledge_results=[ - { - "evidence_pack": [ - { - "source_path": "employee-guide.md / contact / evidence 1", - "excerpt": "材料准备完成后发送至 ops@example.test。", - } - ] - } - ] - ) - - reply = loop._finalize_turn( - session, - "tenant_demo", - "请将材料发送至 ops@example... [1]", - step_result=step_result, - ) - - message = loop.db.added[-1] - assert reply == "请将材料发送至 ops@example.test [1]" - assert isinstance(message, Message) - assert message.content == reply - - -def test_merge_queued_reply_preserves_each_structured_execution_segment() -> None: - loop = object.__new__(AgentLoop) - refund_then_purchase = ( - "好的,已为您提交订单 MOCK7A17191FC9(商品 A1)的退款申请,退款原因为“不想要了”。\n\n" - "接下来为您购买 A3 高阶商品,请确认以下信息:\n" - "- 用户:hm\n" - "- 商品:A3\n" - "- 数量:1\n\n" - "请问确认下单吗?" - ) - purchase_confirmation = ( - "好的,hm。已为您确认购买 A3 高阶商品 1 件,价格 239.0 元。请问确认下单吗?" - ) - - replies, replaced = loop._merge_queued_reply_segment([], refund_then_purchase) - replies, replaced = loop._merge_queued_reply_segment(replies, purchase_confirmation) - - assert replaced is False - assert replies == [refund_then_purchase, purchase_confirmation] - - -def test_merge_queued_reply_keeps_distinct_followup_confirmations() -> None: - loop = object.__new__(AgentLoop) - first = "退款已处理。接下来为您购买 A1,请问确认下单吗?" - second = "好的,hm。已为您确认购买 A3 高阶商品 1 件,价格 239.0 元。请问确认下单吗?" - - replies, replaced = loop._merge_queued_reply_segment([], first) - replies, replaced = loop._merge_queued_reply_segment(replies, second) - - assert replaced is False - assert replies == [first, second] - - -def test_apply_step_result_records_skill_context_for_step_change() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="skill_purchase_001", - active_step_id="collect_user_name", - ) - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(next_step_id="confirm_purchase"), - ) - - assert session.active_step_id == "confirm_purchase" - event_type, payload = loop.events.records[0][2], loop.events.records[0][3] - assert event_type == "skill_step_changed" - assert payload["from_skill_id"] == "skill_purchase_001" - assert payload["to_skill_id"] == "skill_purchase_001" - assert payload["from_step_id"] == "collect_user_name" - assert payload["to_step_id"] == "confirm_purchase" - - -def test_apply_step_result_persists_and_consumes_awaiting_confirmation() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = Skill( - tenant_id="tenant_demo", - skill_id="meeting_room_book", - name="会议室预订", - content_json=_graph_content( - "meeting_room_book", - "会议室预订", - [ - { - "node_id": "confirm_booking", - "name": "确认预订", - "expected_user_info": ["confirmation"], - "allowed_actions": ["answer_user"], - }, - { - "node_id": "book_room", - "type": "tool_call", - "name": "提交预订", - "allowed_actions": ["call_tool:admin.room_book"], - }, - ], - ), - status="published", - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="confirm_booking", - slots_json={"employee_id": "123456"}, - awaiting_input_json={"task_id": "task_booking"}, - ) - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(action="ask_user", reply="请确认是否提交预订?"), - skill, - ) - - assert session.awaiting_input_json == { - "task_id": "task_booking", - "skill_id": "meeting_room_book", - "step_id": "confirm_booking", - "expected_fields": ["confirmation"], - "question_summary": "请确认是否提交预订?", - } - assert session.last_agent_question == "请确认是否提交预订?" - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult( - action="advance", - slot_updates={"confirmation": True}, - next_step_id="book_room", - is_step_completed=True, - ), - skill, - ) - - assert session.active_step_id == "book_room" - assert session.awaiting_input_json == {"task_id": "task_booking"} - assert session.last_agent_question is None - - -def test_apply_step_result_preserves_awaiting_input_after_invalid_next_step() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = _purchase_skill() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="collect_user_name", - awaiting_input_json={ - "skill_id": skill.skill_id, - "step_id": "collect_user_name", - "expected_fields": ["user_name"], - "question_summary": "请提供姓名", - }, - last_agent_question="请提供姓名", - ) - - result = StepAgentResult( - action="advance", - next_step_id="missing_step", - is_step_completed=True, - ) - loop._apply_step_result("tenant_demo", session, result, skill) - - assert session.active_step_id == "collect_user_name" - assert session.awaiting_input_json == { - "skill_id": skill.skill_id, - "step_id": "collect_user_name", - "expected_fields": ["user_name"], - "question_summary": "请提供姓名", - } - assert result.next_step_id is None - assert result.is_step_completed is False - - -def test_apply_step_result_does_not_treat_plain_reply_as_awaiting_input() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = _purchase_skill() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="collect_user_name", - ) - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(action="reply", reply="商品信息已为您保留。"), - skill, - ) - - assert session.awaiting_input_json is None - assert session.last_agent_question is None - - -def test_record_runtime_event_skips_noop_step_change() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="skill_purchase_001", - active_step_id="collect_user_name", - ) - - loop._record_runtime_event( - "tenant_demo", - session, - "skill_purchase_001", - "collect_user_name", - RouterDecision( - decision="continue_active", - target_skill_id="skill_purchase_001", - target_step_id="collect_user_name", - ), - ) - - assert loop.events.records == [] - - -def test_apply_step_result_ignores_next_step_outside_active_skill() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="refund", - active_step_id="check_refund", - ) - step_result = StepAgentResult(next_step_id="collect_user_name", is_step_completed=True) - - loop._apply_step_result("tenant_demo", session, step_result, _refund_skill()) - - assert session.active_step_id == "check_refund" - assert step_result.next_step_id is None - event_type, payload = loop.events.records[0][2], loop.events.records[0][3] - assert event_type == "step_agent_result_repaired" - assert payload["mode"] == "invalid_next_step_ignored" - assert payload["invalid_next_step_id"] == "collect_user_name" - - -def test_apply_step_result_does_not_create_step_without_active_skill() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(next_step_id="confirm_purchase"), - ) - - assert session.active_skill_id is None - assert session.active_step_id is None - assert loop.events.records == [] - - -def test_apply_step_result_queues_parallel_sibling_steps_and_merges() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = _parallel_audit_skill( - [ - {"source_node_id": "start", "next_node_id": "check_payee", "condition": "报文已获取"}, - { - "source_node_id": "start", - "next_node_id": "check_sensitive", - "condition": "报文已获取", - }, - { - "source_node_id": "check_payee", - "next_node_id": "report", - "condition": "一致性检查完成", - }, - { - "source_node_id": "check_sensitive", - "next_node_id": "report", - "condition": "敏感词检查完成", - }, - ] - ) - session = ChatSession( - id="session_parallel", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="start", - slots_json={}, - ) - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(next_step_id="check_payee", is_step_completed=True), - skill, - ) - - assert session.active_step_id == "check_payee" - assert session.slots_json == {GRAPH_PENDING_STEPS_SLOT: ["check_sensitive"]} - - first_branch_result = StepAgentResult(next_step_id="report", is_step_completed=True) - loop._apply_step_result("tenant_demo", session, first_branch_result, skill) - - assert session.active_step_id == "check_sensitive" - assert first_branch_result.next_step_id == "check_sensitive" - assert session.slots_json == {GRAPH_PENDING_STEPS_SLOT: ["report"]} - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(next_step_id="report", is_step_completed=True), - skill, - ) - - assert session.active_step_id == "report" - assert session.slots_json == {} - assert [record[2] for record in loop.events.records].count("skill_step_changed") == 3 - - -def test_apply_step_result_does_not_queue_exclusive_sibling_conditions() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - skill = _parallel_audit_skill( - [ - {"source_node_id": "start", "next_node_id": "approve", "condition": "审核通过"}, - {"source_node_id": "start", "next_node_id": "reject", "condition": "审核拒绝"}, - ] - ) - session = ChatSession( - id="session_exclusive", - tenant_id="tenant_demo", - active_skill_id=skill.skill_id, - active_step_id="start", - slots_json={}, - ) - - loop._apply_step_result( - "tenant_demo", - session, - StepAgentResult(next_step_id="approve", is_step_completed=True), - skill, - ) - - assert session.active_step_id == "approve" - assert session.slots_json == {} - - -def test_terminal_skill_completion_when_required_slots_are_complete() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="reply_ticket_result", - slots_json={"reporter_name": "hm", "asset_id": "EQ-9", "issue_desc": "无法开机"}, - ) - - assert loop._should_complete_skill( - _repair_skill(), - session, - StepAgentResult(is_step_completed=True), - None, - ) - - -def test_terminal_collect_step_can_complete_with_ask_user_action_when_slots_are_complete() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="refund", - active_step_id="collect_refund_reason", - slots_json={"order_id": "A12345", "refund_reason": "不喜欢"}, - ) - - assert loop._should_complete_skill( - _refund_collect_terminal_skill(), - session, - StepAgentResult(is_step_completed=True, next_step_id="collect_refund_reason"), - None, - ) - - -def test_stale_terminal_skill_is_cleared_before_next_route() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="reply_ticket_result", - slots_json={"reporter_name": "hm", "asset_id": "EQ-9", "issue_desc": "无法开机"}, - ) - - loop._finish_stale_completed_skill("tenant_demo", session, [_repair_skill()]) - - assert session.active_skill_id is None - assert session.active_step_id is None - assert session.slots_json == {} - assert loop.events.records[0][2] == "skill_completed" - assert loop.events.records[0][3]["reason"] == "stale_terminal_state" - - -def test_scheduled_task_followup_can_continue_after_stale_terminal_completion() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="reply_ticket_result", - slots_json={"reporter_name": "hm", "asset_id": "EQ-9", "issue_desc": "无法开机"}, - pending_tasks_json=[ - { - "task_id": "task_purchase_after_compare", - "status": "pending", - "skill_id": "purchase", - "target_skill_id": "purchase", - "step_id": "collect_user_name", - "target_step_id": "collect_user_name", - "slots": {"user_name": "hm"}, - "slot_hints": {"user_name": "hm"}, - "intent_summary": "购买比价后更贵的商品", - } - ], - ) - request = _request("自动任务唤醒:完成维修后继续处理购买任务") - request.interaction_mode = "scheduled_task" - - should_continue = loop._should_attempt_queued_task_followup( - request, - session, - [_repair_skill(), _purchase_skill()], - "维修结果已反馈。", - 1, - ) - - assert should_continue is True - assert session.active_skill_id is None - assert session.active_step_id is None - assert session.pending_tasks_json[0]["task_id"] == "task_purchase_after_compare" - assert [record[2] for record in loop.events.records] == [ - "skill_completed", - "scheduled_task_followup_requested", - ] - - -def test_normal_chat_does_not_auto_continue_pending_after_stale_terminal_completion() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - loop.db = FakeDb() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="reply_ticket_result", - slots_json={"reporter_name": "hm", "asset_id": "EQ-9", "issue_desc": "无法开机"}, - pending_tasks_json=[ - { - "task_id": "task_purchase_after_compare", - "status": "pending", - "skill_id": "purchase", - "target_skill_id": "purchase", - "step_id": "collect_user_name", - "target_step_id": "collect_user_name", - "slots": {"user_name": "hm"}, - "slot_hints": {"user_name": "hm"}, - } - ], - ) - - should_continue = loop._should_attempt_queued_task_followup( - _request("普通聊天继续处理"), - session, - [_repair_skill(), _purchase_skill()], - "维修结果已反馈。", - 1, - ) - - assert should_continue is False - assert session.active_skill_id == "repair_ticket" - assert loop.events.records == [] - - -def test_obsolete_suspended_stack_is_cleared() -> None: - loop = object.__new__(AgentLoop) - loop.runtime = SkillRuntime() - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="visitor_badge", - active_step_id="collect_visit_info", - skill_stack_json=[ - { - "skill_id": "repair_ticket", - "step_id": "reply_ticket_result", - "slots": {"reporter_name": "hm", "asset_id": "EQ-9", "issue_desc": "无法开机"}, - } - ], - ) - - loop._finish_stale_completed_skill("tenant_demo", session, [_repair_skill()]) - - assert session.active_skill_id == "visitor_badge" - assert session.skill_stack_json == [] - assert loop.events.records == [] - - -def test_intermediate_step_with_next_step_is_not_completed() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="collect_repair_info", - slots_json={"reporter_name": "hm"}, - ) - - assert not loop._should_complete_skill( - _repair_skill(), - session, - StepAgentResult(is_step_completed=True, next_step_id="reply_ticket_result"), - None, - ) - - -def test_model_can_complete_non_terminal_skill_when_no_next_action() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - active_step_id="collect_repair_info", - slots_json={"reporter_name": "hm"}, - ) - - assert loop._should_complete_skill( - _repair_skill(), - session, - StepAgentResult(reply="好的,已取消本次报修流程。", is_step_completed=True), - None, - ) - - -def test_successful_tool_call_advances_to_final_reply_step() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="refund", - active_step_id="check_refund", - ) - step_result = StepAgentResult(tool_call=_refund_tool_call(), is_step_completed=True) - - advanced = loop._advance_after_successful_tool( - "tenant_demo", - session, - _refund_skill(), - step_result, - ToolResult(tool_name="order.query", success=True, data={"eligible": True}), - ) - - assert advanced - assert session.active_step_id == "reply_result" - assert step_result.next_step_id == "reply_result" - assert loop.events.records[0][2] == "skill_step_changed" - - -def test_answer_step_can_complete_even_if_distilled_order_has_later_satisfied_collect_step() -> ( - None -): - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="refund", - active_step_id="check_refund", - slots_json={"order_id": "A12345", "refund_reason": "商品质量"}, - ) - step_result = StepAgentResult(tool_call=_refund_tool_call(), is_step_completed=True) - - advanced = loop._advance_after_successful_tool( - "tenant_demo", - session, - _refund_skill_with_late_collect_step(), - step_result, - ToolResult(tool_name="order.query", success=True, data={"eligible": True}), - ) - - assert not advanced - assert session.active_step_id == "check_refund" - assert loop._should_complete_skill( - _refund_skill_with_late_collect_step(), - session, - step_result, - ToolResult(tool_name="order.query", success=True, data={"eligible": True}), - ) - - -def test_context_repair_does_not_auto_advance_satisfied_collect_step() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - reply="您好 hm,请问您想购买的商品 ID 是什么?", - slot_updates={"user_name": "hm"}, - next_step_id="collect_user_name", - ), - ] - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={"product_id": "A3", "quantity": 1}, - ) - - step_result = loop._run_step_agent_with_context_repair( - _request("我叫hm"), - session, - _purchase_skill(), - [_purchase_tool(), _order_add_tool()], - _model_config(), - RouterDecision(decision="continue_active", target_skill_id="purchase"), - ) - - assert session.active_step_id == "collect_user_name" - assert loop.step_agent.calls == 1 - assert step_result.tool_call is None - assert not any( - event_type == "skill_step_changed" and payload.get("reason") == "expected_info_satisfied" - for _, _, event_type, payload in loop.events.records - ) - - -def test_required_knowledge_step_forces_query_before_advance() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="leave", - active_step_id="check_policy", - slots_json={"leave_type": "事假"}, - ) - result = StepAgentResult( - action="advance", - reply="根据公司政策可以申请。", - tool_call=ToolCall(name="hr.balance_query", arguments={"employee_id": "E1"}), - knowledge_results=[{"evidence_pack": [{"content": "伪造证据"}]}], - next_step_id="query_balance", - is_step_completed=True, - handoff=True, - ) - - normalized = loop._normalize_required_knowledge_step( - _request("我要请事假"), session, _leave_policy_skill(), result - ) - - assert normalized.action == "query_knowledge" - assert normalized.knowledge_query is not None - assert "leave_type: 事假" in normalized.knowledge_query.query - assert "我要请事假" not in normalized.knowledge_query.query - assert normalized.reply is None - assert normalized.tool_call is None - assert normalized.next_step_id is None - assert normalized.knowledge_results == [] - assert normalized.is_step_completed is False - assert normalized.handoff is False - assert any(record[2] == "knowledge_query_forced" for record in loop.events.records) - - -def test_required_knowledge_step_preserves_missing_field_question() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="leave", - active_step_id="check_policy", - slots_json={}, - ) - result = StepAgentResult( - action="advance", - reply="请问请假类型是什么?", - knowledge_query=KnowledgeQuery(query="通用请假政策"), - tool_call=ToolCall(name="hr.balance_query", arguments={}), - next_step_id="query_balance", - is_step_completed=True, - handoff=True, - ) - - normalized = loop._normalize_required_knowledge_step( - _request("我要请假"), session, _leave_policy_skill(), result - ) - - assert normalized is result - assert normalized.knowledge_query is None - assert normalized.reply == "请问请假类型是什么?" - assert normalized.action == "ask_user" - assert normalized.tool_call is None - assert normalized.next_step_id is None - assert normalized.is_step_completed is False - assert normalized.handoff is False - - -def test_required_knowledge_step_keeps_model_query_but_removes_conflicts() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="leave", - active_step_id="check_policy", - slots_json={"leave_type": "年假"}, - ) - query = KnowledgeQuery(query="年假政策", query_type="policy_check") - result = StepAgentResult( - action="reply", - reply="先直接回答", - knowledge_query=query, - next_step_id="query_balance", - ) - - normalized = loop._normalize_required_knowledge_step( - _request("年假"), session, _leave_policy_skill(), result - ) - - assert normalized.knowledge_query is query - assert normalized.action == "query_knowledge" - assert normalized.reply is None - assert normalized.next_step_id is None - assert not any(record[2] == "knowledge_query_forced" for record in loop.events.records) - - -def test_repeated_knowledge_step_reuses_turn_cache_without_new_search() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop._run_step_agent_once = lambda *args, **kwargs: StepAgentResult( # type: ignore[method-assign] - action="reply", reply="已根据检索结果处理" - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="leave", - active_step_id="check_policy", - ) - key = ("leave", "check_policy") - cached = {"query": {"query": "事假政策"}, "evidence_pack": [{"content": "事假无薪"}]} - _KNOWLEDGE_STEPS_SEEN.set({key}) - _KNOWLEDGE_RESULTS_CACHE.set({key: cached}) - - result = loop._execute_knowledge_query_cycle( - _request("继续"), - session, - _leave_policy_skill(), - [], - _model_config(), - StepAgentResult(knowledge_query=KnowledgeQuery(query="事假政策")), - ) - - assert result.reply == "已根据检索结果处理" - assert result.knowledge_results == [cached] - assert not any(record[2] == "knowledge_query_started" for record in loop.events.records) - assert not any( - event_type == "step_agent_result_repaired" and payload.get("mode") == "schema_tool_call" - for _, _, event_type, payload in loop.events.records - ) - - -def test_context_repair_does_not_infer_tool_when_router_is_clarifying() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent( - [StepAgentResult(reply="请问您想办理哪类业务?", next_step_id="confirm_product")] - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="confirm_product", - slots_json={"product_id": "A1", "quantity": 1, "user_name": "hm"}, - ) - - step_result = loop._run_step_agent_with_context_repair( - _request("我想查询订单"), - session, - _purchase_skill(), - [_purchase_tool()], - _model_config(), - RouterDecision(decision="clarify", target_skill_id="skill_order_query"), - ) - - assert step_result.tool_call is None - assert not any( - event_type == "step_agent_result_repaired" and payload.get("mode") == "schema_tool_call" - for _, _, event_type, payload in loop.events.records - ) - - -def test_model_slot_validation_retry_can_complete_missed_quantity() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - reply="好的,hm。请问您想购买多少件 A1?", - slot_updates={"user_name": "hm", "product_id": "A1"}, - next_step_id="collect_user_name", - ), - StepAgentResult( - reply="正在为您创建订单,请稍候。", - slot_updates={"quantity": 1}, - tool_call=ToolCall( - name="product.purchase", arguments={"product_id": "A1", "quantity": 1} - ), - ), - ] - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={}, - ) - - step_result = loop._run_step_agent_with_context_repair( - _request("我要买一个A1,我叫hm"), - session, - _purchase_skill(), - [_purchase_tool()], - _model_config(), - RouterDecision(decision="start_new_task", target_skill_id="purchase"), - ) - - assert loop.step_agent.calls == 2 - assert session.slots_json["user_name"] == "hm" - assert session.slots_json["product_id"] == "A1" - assert session.slots_json["quantity"] == 1 - assert step_result.tool_call is not None - assert step_result.tool_call.name == "product.purchase" - assert any( - event_type == "step_agent_result_repaired" and payload.get("mode") == "slot_validation" - for _, _, event_type, payload in loop.events.records - ) - assert not any( - event_type == "skill_step_changed" and payload.get("reason") == "expected_info_satisfied" - for _, _, event_type, payload in loop.events.records - ) - - -def test_step_agent_receives_full_conversation_context_within_budget() -> None: - rows = [ - Message( - tenant_id="tenant_demo", - session_id="session_test", - role="user" if index % 2 == 0 else "assistant", - content=f"message {index}", - ) - for index in range(16) - ] - loop = object.__new__(AgentLoop) - loop.db = FakeMessageDb(rows) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent([StepAgentResult(reply="ok")]) - session = ChatSession(id="session_test", tenant_id="tenant_demo") - - loop._run_step_agent_once( - _request("message 15"), - session, - None, - [], - _model_config(), - RouterDecision(decision="clarify"), - ) - - _args, kwargs = loop.step_agent.call_args[0] - recent_messages = kwargs["recent_messages"] - conversation_context = kwargs["conversation_context"] - assert len(recent_messages) == 16 - assert recent_messages[0]["content"] == "message 0" - assert recent_messages[-1]["content"] == "message 15" - assert conversation_context["metadata"]["compacted"] is False - assert conversation_context["metadata"]["total_messages"] == 16 - assert kwargs["current_knowledge"] is None - - -def test_all_agent_stages_share_the_same_full_conversation_context() -> None: - rows = [ - Message( - tenant_id="tenant_demo", - session_id="session_test", - role="user" if index % 2 == 0 else "assistant", - content=f"message {index}", - ) - for index in range(16) - ] - loop = object.__new__(AgentLoop) - loop.db = FakeMessageDb(rows) - session = ChatSession(id="session_test", tenant_id="tenant_demo") - - context = loop._conversation_context(session) - - assert len(context["messages"]) == 16 - assert context["messages"][0]["content"] == "message 0" - assert context["messages"][-1]["content"] == "message 15" - assert context["metadata"]["total_messages"] == 16 - - -def test_model_slot_validation_retry_does_not_fill_without_model_progress() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult(reply="请问您想购买多少件 A1?", next_step_id="collect_user_name"), - StepAgentResult(reply="请问您想购买多少件 A1?", next_step_id="collect_user_name"), - ] - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={"product_id": "A1", "user_name": "hm"}, - ) - - step_result = loop._run_step_agent_with_context_repair( - _request("随便看看"), - session, - _purchase_skill(), - [_purchase_tool()], - _model_config(), - RouterDecision(decision="continue_active", target_skill_id="purchase"), - ) - - assert loop.step_agent.calls == 2 - assert "quantity" not in session.slots_json - assert step_result.tool_call is None - assert not any( - event_type == "step_agent_result_repaired" and payload.get("mode") == "slot_validation" - for _, _, event_type, payload in loop.events.records - ) - - -def test_start_new_task_slot_validation_accepts_reply_repair() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - reply="好的,hm!请问您想购买什么商品?另外,请提供您的姓名以便我们为您下单。", - slot_updates={"user_name": "hm"}, - next_step_id="collect_user_name", - ), - StepAgentResult( - reply="好的,hm!请问您想购买什么商品?需要购买多少件?", - next_step_id="collect_user_name", - ), - ] - ) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_user_name", - slots_json={}, - ) - - step_result = loop._run_step_agent_with_context_repair( - _request("我想买东西"), - session, - _purchase_skill(), - [_purchase_tool()], - _model_config(), - RouterDecision(decision="start_new_task", target_skill_id="purchase"), - memory_context=[ - { - "kind": "profile", - "content": "hm", - "metadata": {"key": "preferred_name"}, - } - ], - conversation_context={"messages": [{"role": "user", "content": "我想买东西"}]}, - ) - - assert loop.step_agent.calls == 2 - assert session.slots_json["user_name"] == "hm" - assert step_result.reply == "好的,hm!请问您想购买什么商品?需要购买多少件?" - assert any( - event_type == "step_agent_result_repaired" and payload.get("mode") == "slot_validation" - for _, _, event_type, payload in loop.events.records - ) - - -def test_tool_step_self_loop_advances_to_reply_and_completes_after_success() -> None: - loop = object.__new__(AgentLoop) - loop.events = FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="confirm_product", - slots_json={"product_id": "A1", "quantity": 1, "user_name": "hm"}, - ) - step_result = StepAgentResult( - tool_call=ToolCall(name="product.purchase", arguments={"product_id": "A1", "quantity": 1}), - next_step_id="confirm_product", - is_step_completed=True, - ) - - advanced = loop._advance_after_successful_tool( - "tenant_demo", - session, - _purchase_skill_with_incomplete_required_info(), - step_result, - ToolResult(tool_name="product.purchase", success=True, data={"order_id": "MOCK-1"}), - ) - - assert advanced - assert session.active_step_id == "reply_result" - assert step_result.next_step_id == "reply_result" - assert loop._should_complete_skill( - _purchase_skill_with_incomplete_required_info(), - session, - step_result, - ToolResult(tool_name="product.purchase", success=True, data={"order_id": "MOCK-1"}), - ) - - -def test_tool_continuation_is_model_driven_and_accumulates_results() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - loop.tool_executor = _RecordingPriceToolExecutor() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A3"}), - is_step_completed=True, - ), - StepAgentResult( - reply="A1 和 A3 均已查到,可以给出比价结果。", - next_step_id="reply_result", - is_step_completed=True, - ), - ] - ) - loop._recent_messages = lambda session: [] # type: ignore[method-assign] - loop._tool_activity_payload = lambda tenant_id, name, result, *args: { # type: ignore[method-assign] - "toolName": name, - "toolCallId": args[1] if len(args) > 1 else "", - "content": result.model_dump(mode="json"), - "success": result.success, - "isError": not result.success, - } - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="price_compare", - active_step_id="query_price", - slots_json={"product_name_1": "A1", "product_name_2": "A3"}, - ) - - stream_events: list[tuple[str, dict[str, object]]] = [] - step_result, tool_result = loop._execute_tool_action_cycle( - _request("我想比下 A1 和 A3 的价格"), - session, - _price_compare_skill(), - [_price_query_tool()], - _model_config(), - StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A1"}), - is_step_completed=True, - ), - stream_events, - ) - - assert [call.arguments["product_name"] for call in loop.tool_executor.calls] == ["A1", "A3"] - assert loop.step_agent.calls == 2 - assert tool_result is not None - assert tool_result.data["product_name"] == "A3" - assert step_result.tool_call is None - assert session.active_step_id == "reply_result" - assert len(session.slots_json["_tool_results"]) == 2 - tool_result_events = [payload for event, payload in stream_events if event == "tool_result"] - assert len(tool_result_events) == 2 - assert tool_result_events[0]["toolCallId"] != tool_result_events[1]["toolCallId"] - assert any(event == "agent_loop_continued" for event, _ in stream_events) - - -def test_tool_continuation_respects_configured_action_limit() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - loop.tool_executor = _RecordingPriceToolExecutor() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A3"}), - is_step_completed=True, - ) - ] - ) - loop._recent_messages = lambda session: [] # type: ignore[method-assign] - loop._tool_activity_payload = lambda tenant_id, name, result, *args: { # type: ignore[method-assign] - "toolName": name, - "toolCallId": args[1] if len(args) > 1 else "", - "content": result.model_dump(mode="json"), - "success": result.success, - "isError": not result.success, - } - loop._get_agent_loop_max_actions = lambda tenant_id: 1 # type: ignore[method-assign] - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="price_compare", - active_step_id="query_price", - slots_json={"product_name_1": "A1", "product_name_2": "A3"}, - ) - - loop._execute_tool_action_cycle( - _request("我想比下 A1 和 A3 的价格"), - session, - _price_compare_skill(), - [_price_query_tool()], - _model_config(), - StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A1"}), - is_step_completed=True, - ), - [], - ) - - assert [call.arguments["product_name"] for call in loop.tool_executor.calls] == ["A1"] - assert loop.step_agent.calls == 1 - assert len(session.slots_json["_tool_results"]) == 1 - - -def test_duplicate_tool_call_with_reply_completes_from_existing_tool_result() -> None: - loop = object.__new__(AgentLoop) - loop.db = FakeDb() - loop.events = FakeEvents() - loop.tool_executor = _RecordingPriceToolExecutor() - loop.step_agent = _FakeStepAgent( - [ - StepAgentResult( - reply="A1 的价格已查到,可以继续。", - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A1"}), - is_step_completed=False, - ) - ] - ) - loop._recent_messages = lambda session: [] # type: ignore[method-assign] - loop._tool_activity_payload = lambda tenant_id, name, result, *args: { # type: ignore[method-assign] - "toolName": name, - "toolCallId": args[1] if len(args) > 1 else "", - "content": result.model_dump(mode="json"), - "success": result.success, - "isError": not result.success, - } - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="price_compare", - active_step_id="step_query_price_1", - slots_json={"product_name_1": "A1"}, - ) - - step_result, tool_result = loop._execute_tool_action_cycle( - _request("查 A1 价格"), - session, - _price_compare_skill(), - [_price_query_tool()], - _model_config(), - StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A1"}), - is_step_completed=True, - ), - [], - ) - - assert [call.arguments["product_name"] for call in loop.tool_executor.calls] == ["A1"] - assert tool_result is not None and tool_result.success is True - assert step_result.tool_call is None - assert step_result.is_step_completed is True - assert step_result.reply == "A1 的价格已查到,可以继续。" - assert not any(record[2] == "agent_loop_stopped" for record in loop.events.records) - assert any( - record[2] == "agent_loop_completed" and record[3]["mode"] == "respond_after_duplicate" - for record in loop.events.records - ) - - -def _repair_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="repair_ticket", - name="设备报修", - content_json=_graph_content( - "repair_ticket", - "设备报修", - [ - { - "node_id": "collect_repair_info", - "name": "收集报修信息", - "expected_user_info": ["reporter_name", "asset_id", "issue_desc"], - "allowed_actions": ["ask_user"], - }, - { - "node_id": "reply_ticket_result", - "name": "反馈工单结果", - "expected_user_info": [], - "allowed_actions": ["answer_user", "handoff_human"], - }, - ], - required_info=["reporter_name", "asset_id", "issue_desc"], - ), - status="published", - ) - - -def _refund_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="refund", - name="售后退款流程", - content_json=_graph_content( - "refund", - "售后退款流程", - [ - { - "node_id": "check_refund", - "type": "tool_call", - "name": "核实退款条件", - "expected_user_info": ["order_id", "refund_reason"], - "allowed_actions": ["continue_flow", "call_tool:order.query"], - }, - { - "node_id": "reply_result", - "name": "反馈结果", - "expected_user_info": [], - "allowed_actions": ["answer_user", "handoff_human"], - }, - ], - required_info=["order_id", "refund_reason"], - ), - status="published", - ) - - -def _parallel_audit_skill(edges: list[dict[str, object]]) -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="skill_parallel_audit", - name="并行审核", - content_json={ - "skill_id": "skill_parallel_audit", - "name": "并行审核", - "required_info": ["message_content"], - "nodes": [ - { - "node_id": "start", - "type": "collect_info", - "name": "收集信息", - "instruction": "收集用户报文。", - "expected_user_info": ["message_content"], - "allowed_actions": ["ask_user"], - }, - { - "node_id": "check_payee", - "type": "condition", - "name": "收款方一致性检查", - "instruction": "检查收款方是否一致。", - "expected_user_info": [], - "allowed_actions": ["continue_flow"], - }, - { - "node_id": "check_sensitive", - "type": "condition", - "name": "敏感词检查", - "instruction": "检查敏感词。", - "expected_user_info": [], - "allowed_actions": ["continue_flow"], - }, - { - "node_id": "approve", - "type": "response", - "name": "通过", - "instruction": "反馈通过。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - { - "node_id": "reject", - "type": "response", - "name": "拒绝", - "instruction": "反馈拒绝。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - { - "node_id": "report", - "type": "response", - "name": "生成报告", - "instruction": "汇总检查结果。", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ], - "edges": [ - { - "source_node_id": str(edge["source_node_id"]), - "next_node_id": str(edge["next_node_id"]), - "condition": str(edge.get("condition") or ""), - "priority": index, - } - for index, edge in enumerate(edges) - ], - "start_node_id": "start", - "terminal_node_ids": ["report", "approve", "reject"], - }, - status="published", - ) - - -def _refund_collect_terminal_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="refund", - name="售后退款流程", - content_json=_graph_content( - "refund", - "售后退款流程", - [ - { - "node_id": "collect_order", - "name": "收集订单号", - "expected_user_info": ["order_id"], - "allowed_actions": ["ask_user", "continue_flow"], - }, - { - "node_id": "collect_refund_reason", - "name": "收集退款原因", - "expected_user_info": ["refund_reason"], - "allowed_actions": ["ask_user", "continue_flow"], - }, - ], - required_info=["order_id", "refund_reason"], - ), - status="published", - ) - - -def _refund_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="order.query", - display_name="订单查询", - method="POST", - url="http://localhost:8000/api/mock/order/query", - input_schema={ - "type": "object", - "properties": { - "order_id": {"type": "string"}, - "refund_reason": {"type": "string"}, - }, - "required": ["order_id", "refund_reason"], - }, - allowed_skills_json=["refund"], - enabled=True, - ) - - -def _refund_skill_with_late_collect_step() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="refund", - name="售后退款流程", - content_json=_graph_content( - "refund", - "售后退款流程", - [ - { - "node_id": "collect_order", - "type": "tool_call", - "name": "收集订单", - "expected_user_info": ["order_id"], - "allowed_actions": ["ask_user", "call_tool:order.query"], - }, - { - "node_id": "check_refund", - "name": "查询退款资格", - "expected_user_info": [], - "allowed_actions": ["answer_user", "handoff_human"], - }, - { - "node_id": "collect_refund_reason", - "name": "收集退款原因", - "expected_user_info": ["refund_reason"], - "allowed_actions": ["ask_user", "continue_flow"], - }, - ], - required_info=["order_id", "refund_reason"], - ), - status="published", - ) - - -def _refund_tool_call(): - return ToolCall( - name="order.query", - arguments={"order_id": "A12345", "refund_reason": "商品质量"}, - ) - - -class _FakeStepAgent: - def __init__(self, results: list[StepAgentResult]) -> None: - self.results = results - self.calls = 0 - self.call_args: list[tuple[tuple[object, ...], dict[str, object]]] = [] - - def run(self, *args: object, **kwargs: object) -> StepAgentResult: - self.call_args.append((args, kwargs)) - result = self.results[min(self.calls, len(self.results) - 1)] - self.calls += 1 - return result - - -class _RecordingPriceToolExecutor: - def __init__(self) -> None: - self.calls: list[ToolCall] = [] - - def execute( - self, - tenant_id: str, - tool_call: ToolCall, - active_skill_id: str | None = None, - agent_id: str | None = None, - ) -> ToolResult: - self.calls.append(tool_call) - product_name = str(tool_call.arguments.get("product_name") or "") - return ToolResult( - tool_name=tool_call.name, - success=True, - data={ - "product_name": product_name, - "found": True, - "price": 129 if product_name == "A1" else 239, - }, - ) - - -def _request(message: str): - from app.session.session_schema import ChatTurnRequest - - return ChatTurnRequest(tenant_id="tenant_demo", session_id="session_test", message=message) - - -def _model_config(): - from app.db.models import ModelConfig - - return ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo") - - -def _graph_content( - skill_id: str, - name: str, - nodes: list[dict[str, object]], - *, - required_info: list[str] | None = None, -) -> dict[str, object]: - normalized_nodes = [ - { - "node_id": str(node["node_id"]), - "type": node.get("type") - or ("collect_info" if node.get("expected_user_info") else "response"), - "name": str(node.get("name") or node["node_id"]), - "instruction": str(node.get("instruction") or ""), - "expected_user_info": list(node.get("expected_user_info") or []), - "allowed_actions": list(node.get("allowed_actions") or []), - "knowledge_scope": dict(node.get("knowledge_scope") or {}), - "metadata": dict(node.get("metadata") or {}), - } - for node in nodes - ] - return { - "skill_id": skill_id, - "name": name, - "required_info": required_info or [], - "nodes": normalized_nodes, - "edges": [ - { - "source_node_id": normalized_nodes[index]["node_id"], - "next_node_id": normalized_nodes[index + 1]["node_id"], - "priority": index, - "label": "默认推进", - } - for index in range(len(normalized_nodes) - 1) - ], - "start_node_id": normalized_nodes[0]["node_id"], - "terminal_node_ids": [normalized_nodes[-1]["node_id"]], - } - - -def _purchase_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="purchase", - name="购买商品", - content_json=_graph_content( - "purchase", - "购买商品", - [ - { - "node_id": "collect_user_name", - "name": "收集用户与商品", - "expected_user_info": ["user_name", "product_id", "quantity"], - "allowed_actions": ["ask_user"], - }, - { - "node_id": "confirm_product", - "type": "tool_call", - "name": "创建订单", - "expected_user_info": ["product_id"], - "allowed_actions": ["call_tool:product.purchase", "call_tool:order.add"], - }, - { - "node_id": "reply_result", - "name": "反馈订单", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ], - required_info=["user_name", "product_id", "quantity"], - ), - status="published", - ) - - -def _leave_policy_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="leave", - name="请假申请", - content_json=_graph_content( - "leave", - "请假申请", - [ - { - "node_id": "check_policy", - "type": "knowledge_query", - "name": "检索假期政策", - "instruction": "根据请假类型检索假期政策。", - "expected_user_info": ["leave_type"], - "allowed_actions": ["answer_user"], - "knowledge_scope": {"query_fields": ["leave_type"]}, - }, - { - "node_id": "query_balance", - "type": "tool_call", - "name": "查询余额", - "allowed_actions": ["call_tool:hr.balance_query"], - }, - ], - required_info=["leave_type"], - ), - status="published", - ) - - -def _purchase_skill_with_incomplete_required_info() -> Skill: - skill = _purchase_skill() - skill.content_json = { - **(skill.content_json or {}), - "required_info": ["user_id", "product_id", "quantity"], - } - return skill - - -def _purchase_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="product.purchase", - display_name="购买商品", - method="POST", - url="http://localhost:8000/api/mock/product/purchase", - input_schema={ - "type": "object", - "properties": { - "product_id": {"type": "string"}, - "quantity": {"type": "integer"}, - "user_id": {"type": "string"}, - }, - "required": ["product_id"], - }, - enabled=True, - ) - - -def _order_add_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="order.add", - display_name="订单添加", - method="POST", - url="http://localhost:8000/api/mock/order/add", - input_schema={ - "type": "object", - "properties": {"product_id": {"type": "string"}}, - "required": ["product_id"], - }, - enabled=True, - ) - - -def _price_compare_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="price_compare", - name="商品比价", - content_json=_graph_content( - "price_compare", - "商品比价", - [ - { - "node_id": "collect_products", - "name": "收集商品", - "expected_user_info": ["product_name_1", "product_name_2"], - "allowed_actions": ["ask_user"], - }, - { - "node_id": "query_price", - "type": "tool_call", - "name": "查询价格", - "expected_user_info": [], - "allowed_actions": ["call_tool:product.price_query"], - }, - { - "node_id": "reply_result", - "name": "反馈结果", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ], - required_info=["product_name_1", "product_name_2"], - ), - status="published", - ) - - -def _price_query_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="product.price_query", - display_name="商品价格查询", - method="POST", - url="http://localhost:8000/api/mock/product/price-query", - input_schema={ - "type": "object", - "properties": {"product_name": {"type": "string"}}, - "required": ["product_name"], - }, - enabled=True, - ) - - -def test_step_agent_tools_are_scoped_to_active_skill() -> None: - loop = object.__new__(AgentLoop) - purchase_skill = _purchase_skill() - price_skill = _price_compare_skill() - price_tool = _price_query_tool() - price_tool.allowed_skills_json = [price_skill.skill_id] - global_tool = _order_add_tool() - - purchase_tool_names = { - tool.name - for tool in loop._step_agent_tools( - purchase_skill, - [price_tool, global_tool], - active_step_id="confirm_product", - slots={"product_id": "A1"}, - ) - } - price_tool_names = { - tool.name - for tool in loop._step_agent_tools( - price_skill, - [price_tool, global_tool], - active_step_id="query_price", - ) - } - - assert purchase_tool_names == {"order.add"} - assert price_tool_names == {"product.price_query"} - assert ( - loop._step_agent_tools( - purchase_skill, - [price_tool, global_tool], - active_step_id="collect_user_name", - slots={}, - ) - == [] - ) - assert loop._step_agent_tools(None, [price_tool, global_tool]) == [] - - -def _refund_skill_with_tool_collect_step() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="refund", - name="退款", - content_json=_graph_content( - "refund", - "退款", - [ - { - "node_id": "collect_order", - "type": "tool_call", - "name": "收集订单", - "expected_user_info": ["order_id"], - "allowed_actions": ["ask_user", "call_tool:order.query"], - }, - { - "node_id": "reply_result", - "name": "反馈结果", - "expected_user_info": [], - "allowed_actions": ["answer_user"], - }, - ], - required_info=["order_id"], - ), - status="published", - ) diff --git a/backend/tests/test_agent_loop_decomposition_seams.py b/backend/tests/test_agent_loop_decomposition_seams.py deleted file mode 100644 index fd8cb142..00000000 --- a/backend/tests/test_agent_loop_decomposition_seams.py +++ /dev/null @@ -1,331 +0,0 @@ -from typing import Any - -import app.core.agent_loop as agent_loop_module -from app.core.agent_loop import AgentLoop -from app.core.human_handoff_service import HumanHandoffService -from app.core.legacy_tool_action import LegacyToolAction -from app.db.models import ChatSession, GeneralSkill, ModelConfig, Skill, Tool -from app.knowledge.schema import KnowledgeSearchResponse -from app.session.session_schema import ( - AwaitingInput, - ChatTurnRequest, - RouterDecision, - StepAgentResult, -) -from app.tools.tool_schema import ToolCall, ToolResult - - -def _loop() -> AgentLoop: - return AgentLoop.__new__(AgentLoop) - - -def test_slot_hydration_preserves_agent_loop_private_seams(monkeypatch) -> None: - loop = _loop() - calls: list[str] = [] - monkeypatch.setattr( - loop, - "_slot_hydration_patch", - lambda skill, slots, memory: calls.append("patch") or {"user_name": "A"}, - ) - monkeypatch.setattr( - loop, - "_trim_satisfied_awaiting_fields", - lambda decision, slots: calls.append("trim") or [], - ) - skill = Skill(tenant_id="tenant", skill_id="skill", version="1", name="Skill") - decision = RouterDecision( - decision="start_new_task", - target_skill_id="skill", - awaiting_input=AwaitingInput(expected_fields=["user_name"]), - ) - - loop._hydrate_router_decision_from_context( - ChatSession(id="session", tenant_id="tenant"), decision, [skill], [] - ) - - assert calls == ["patch", "trim"] - - -def test_tool_replay_preserves_agent_loop_private_seams(monkeypatch) -> None: - loop = _loop() - calls: list[str] = [] - monkeypatch.setattr( - loop, - "_tool_call_history", - lambda slots: calls.append("history") or [], - ) - monkeypatch.setattr( - loop, - "_tool_call_signature", - lambda call: calls.append("call") or call.name, - ) - monkeypatch.setattr( - loop, - "_tool_history_signature", - lambda item: calls.append("item") or str(item), - ) - session = ChatSession(id="session", tenant_id="tenant") - call = ToolCall(name="orders.create", arguments={}) - - loop._record_tool_result_in_slots( - session, call, ToolResult(tool_name=call.name, success=True) - ) - - assert calls == ["history", "call"] - - -def test_tool_config_and_citation_projection_preserve_private_seams(monkeypatch) -> None: - loop = _loop() - parsed: list[object] = [] - monkeypatch.setattr( - loop, - "_idempotency_enabled_value", - lambda value: parsed.append(value) or True, - ) - tool = Tool( - tenant_id="tenant", - name="orders.create", - config_json={"idempotency": {"enabled": "custom"}}, - ) - assert loop._tool_idempotency_config(tool) == (True, None) - assert parsed == ["custom"] - - citations: list[list[dict[str, Any]]] = [] - monkeypatch.setattr( - loop, - "_dedupe_knowledge_citations", - lambda items: citations.append(items) or [{"title": "patched"}], - ) - metadata = loop._assistant_message_metadata( - StepAgentResult( - knowledge_results=[{"chunks": [{"title": "source", "content": "body"}]}] - ), - ChatSession(id="session", tenant_id="tenant"), - ) - assert citations - assert metadata["knowledge_citations"] == [{"title": "patched"}] - - -def test_handoff_service_receives_agent_loop_private_callbacks(monkeypatch) -> None: - loop = _loop() - loop.db = object() # type: ignore[assignment] - loop.events = object() # type: ignore[assignment] - calls: list[str] = [] - monkeypatch.setattr(loop, "_current_skill_step", lambda skill, step_id: {}) - monkeypatch.setattr( - loop, - "_human_handoff_assignee_user_id", - lambda tenant_id, agent_id, user_id: calls.append("assignee") or user_id, - ) - monkeypatch.setattr( - loop, - "_human_handoff_context_summary", - lambda session: calls.append("context") or "summary", - ) - monkeypatch.setattr( - loop, - "_human_handoff_pending_question", - lambda step, result: calls.append("question") or "question", - ) - - def fake_create( - service: HumanHandoffService, - tenant_id: str, - session: ChatSession, - result: StepAgentResult, - **callbacks: Any, - ) -> object: - current_step = callbacks["current_step_resolver"]() - callbacks["assignee_resolver"](tenant_id, session.agent_id, session.user_id) - callbacks["context_summary"](session) - callbacks["pending_question"](current_step, result) - return object() - - monkeypatch.setattr(HumanHandoffService, "create", fake_create) - skill = Skill(tenant_id="tenant", skill_id="skill", version="1", name="Skill") - loop._create_human_handoff_request( - "tenant", - ChatSession( - id="session", - tenant_id="tenant", - agent_id="agent", - user_id="user", - active_step_id="step", - ), - skill, - StepAgentResult(), - ) - - assert calls == ["assignee", "context", "question"] - - -def test_existing_handoff_short_circuits_before_step_resolution(monkeypatch) -> None: - class ExistingQuery: - def where(self, *args: Any) -> "ExistingQuery": - return self - - def first(self) -> object: - return object() - - class ExistingDb: - def exec(self, statement: Any) -> ExistingQuery: - return ExistingQuery() - - service = HumanHandoffService(ExistingDb(), object()) # type: ignore[arg-type] - existing = type( - "ExistingHandoff", - (), - {"id": "handoff", "pending_question": "pending"}, - )() - monkeypatch.setattr( - service.db, - "exec", - lambda statement: type("Result", (), {"first": lambda self: existing})(), - ) - session = ChatSession(id="session", tenant_id="tenant") - - returned = service.create( - "tenant", - session, - StepAgentResult(), - current_step_resolver=lambda: (_ for _ in ()).throw(AssertionError("called")), - assignee_resolver=lambda tenant, agent, user: None, - context_summary=lambda value: "", - pending_question=lambda step, result: "", - ) - - assert returned is existing - assert session.awaiting_input_json == { - "type": "human_handoff", - "handoff_id": "handoff", - "pending_question": "pending", - } - - -def test_persona_prompt_preserves_module_level_patch_seams(monkeypatch) -> None: - loop = _loop() - agent = type("Agent", (), {"is_overall": False})() - monkeypatch.setattr(loop, "_get_agent_profile", lambda tenant_id, agent_id: agent) - monkeypatch.setattr( - agent_loop_module, - "_agent_identity_prompt", - lambda value: "patched persona", - ) - - assert loop._get_persona_prompt("tenant", "agent") == "patched persona" - - -def test_tool_action_preserves_agent_loop_id_seam(monkeypatch) -> None: - loop = _loop() - loop.db = object() # type: ignore[assignment] - loop.events = object() # type: ignore[assignment] - generated: list[str] = [] - monkeypatch.setattr( - agent_loop_module, - "new_id", - lambda prefix: generated.append(prefix) or "patched-id", - ) - - def fake_execute(service: LegacyToolAction, *args: Any) -> tuple[Any, None]: - callbacks = args[-1] - assert callbacks.new_id("toolcall") == "patched-id" - assert callbacks.is_general_skill_tool("custom.run") - return args[5], None - - monkeypatch.setattr(LegacyToolAction, "execute_cycle", fake_execute) - monkeypatch.setattr(agent_loop_module, "GENERAL_SKILL_TOOL_PREFIX", "custom.") - result = StepAgentResult() - returned, tool_result = loop._execute_tool_action_cycle( - ChatTurnRequest(tenant_id="tenant", message="test"), - ChatSession(id="session", tenant_id="tenant"), - None, - [], - None, - result, - ) - - assert returned is result - assert tool_result is None - assert generated == ["toolcall"] - - -def test_knowledge_action_preserves_agent_loop_service_factory_seam(monkeypatch) -> None: - loop = _loop() - loop.db = object() # type: ignore[assignment] - loop.events = object() # type: ignore[assignment] - monkeypatch.setattr(loop, "_agent_visible_knowledge_base_ids", lambda *args: []) - monkeypatch.setattr(loop, "_agent_requires_resource_filter", lambda *args: False) - created: list[object] = [] - - class FakeKnowledgeService: - def __init__(self, db: object) -> None: - created.append(db) - - def search(self, request: object, model_config: object) -> KnowledgeSearchResponse: - return KnowledgeSearchResponse( - selected_buckets=[], - chunks=[], - trace=[], - route_trace=[], - selected_documents=[], - expanded_sections=[], - evidence_pack=[], - ) - - monkeypatch.setattr(agent_loop_module, "KnowledgeService", FakeKnowledgeService) - - assert loop._knowledge_items_for_message("tenant", "agent", "question") is None - assert created == [loop.db] - - -def test_general_skill_early_returns_do_not_resolve_runner_or_events(monkeypatch) -> None: - loop = _loop() - monkeypatch.setattr(loop, "_list_published_general_skills", lambda *args: []) - request = ChatTurnRequest(tenant_id="tenant", message="test") - session = ChatSession(id="session", tenant_id="tenant") - - invalid = loop._execute_general_skill_tool_call( - request, - session, - ToolCall(name="general_skill.", arguments={}), - None, - ) - missing = loop._execute_general_skill_tool_call( - request, - session, - ToolCall(name="general_skill.missing", arguments={}), - None, - ) - - assert invalid.error is not None - assert invalid.error.code == "INVALID_GENERAL_SKILL" - assert missing.error is not None - assert missing.error.code == "GENERAL_SKILL_NOT_FOUND" - - -def test_general_skill_guard_early_return_does_not_resolve_selector_or_events( - monkeypatch, -) -> None: - loop = _loop() - loop._validated_general_skill_calls = set() - monkeypatch.setattr(loop, "_list_published_general_skills", lambda *args: []) - skill = GeneralSkill( - tenant_id="tenant", - slug="weather", - name="Weather", - version="1.0.0", - ) - - result = loop._validate_general_skill_tool_match( - ChatTurnRequest(tenant_id="tenant", message=""), - ChatSession(id="session", tenant_id="tenant"), - ToolCall(name="general_skill.weather", arguments={}), - skill, - "", - ModelConfig(tenant_id="tenant", name="model", model="test"), - None, - ) - - assert result is not None - assert result.error is not None - assert result.error.code == "GENERAL_SKILL_MISMATCH" diff --git a/backend/tests/test_chat_agent_binding.py b/backend/tests/test_chat_agent_binding.py index a33ea513..53cf9ece 100644 --- a/backend/tests/test_chat_agent_binding.py +++ b/backend/tests/test_chat_agent_binding.py @@ -13,7 +13,6 @@ create_chat_session, list_chat_sessions, ) -from app.agents.branching import ensure_private_resource_binding from app.core.agent_loop import AgentLoop, AgentLoopPreconditionError from app.db.models import ( AgentEvent, @@ -24,12 +23,10 @@ PersonaConfig, ScheduledTaskRun, Tenant, - Tool, User, utc_now, ) from app.session.session_schema import ChatSessionCreateRequest, ChatTurnRequest -from app.tools.tool_schema import ToolCall def test_existing_chat_session_cannot_switch_agent() -> None: @@ -318,92 +315,6 @@ def test_chat_turn_can_select_enabled_model_config() -> None: assert model.id == "model_selected" -def test_agent_loop_only_exposes_tools_bound_to_current_employee() -> None: - with _test_session() as db: - db.add(Tenant(id="tenant_demo", name="Demo")) - agent_a = AgentProfile(id="agent_a", tenant_id="tenant_demo", name="员工 A") - agent_b = AgentProfile(id="agent_b", tenant_id="tenant_demo", name="员工 B") - tool_a = Tool( - id="tool_a", - tenant_id="tenant_demo", - name="tool.a", - method="POST", - url="https://example.test/a", - enabled=True, - ) - tool_b = Tool( - id="tool_b", - tenant_id="tenant_demo", - name="tool.b", - method="POST", - url="https://example.test/b", - enabled=True, - ) - db.add(agent_a) - db.add(agent_b) - db.add(tool_a) - db.add(tool_b) - db.flush() - ensure_private_resource_binding(db, "tenant_demo", agent_a.id, "tool", tool_a.id, "active") - ensure_private_resource_binding(db, "tenant_demo", agent_b.id, "tool", tool_b.id, "active") - db.commit() - - loop = AgentLoop(db) - - assert [row.id for row in loop._list_enabled_tools("tenant_demo", agent_a.id)] == [ - tool_a.id - ] - assert [row.id for row in loop._list_enabled_tools("tenant_demo", agent_b.id)] == [ - tool_b.id - ] - - -def test_agent_loop_rejects_unbound_tool_before_execution_or_replay() -> None: - with _test_session() as db: - db.add(Tenant(id="tenant_demo", name="Demo")) - owner = AgentProfile(id="agent_owner", tenant_id="tenant_demo", name="员工 A") - other = AgentProfile(id="agent_other", tenant_id="tenant_demo", name="员工 B") - tool = Tool( - id="tool_private", - tenant_id="tenant_demo", - name="private.lookup", - method="POST", - url="https://example.test/private", - enabled=True, - ) - session = ChatSession( - id="session_other", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id=other.id, - ) - db.add(owner) - db.add(other) - db.add(tool) - db.add(session) - db.flush() - ensure_private_resource_binding(db, "tenant_demo", owner.id, "tool", tool.id, "active") - db.commit() - - result = AgentLoop(db)._execute_tool_call( - ChatTurnRequest( - tenant_id="tenant_demo", - session_id=session.id, - user_id="user_demo", - agent_id=other.id, - message="执行私有工具", - ), - session, - ToolCall(name=tool.name, arguments={}), - ) - - assert result.success is False - assert result.error is not None - assert result.error.code == "NOT_ALLOWED" - event_types = [row.event_type for row in db.exec(select(AgentEvent)).all()] - assert event_types == ["tool_call_started", "tool_call_finished"] - - def test_chat_turn_rejects_disabled_selected_model_config() -> None: with _test_session() as db: db.add(Tenant(id="tenant_demo", name="Demo")) diff --git a/backend/tests/test_conversation_projection.py b/backend/tests/test_conversation_projection.py index 523d9cf5..628c18b3 100644 --- a/backend/tests/test_conversation_projection.py +++ b/backend/tests/test_conversation_projection.py @@ -1,4 +1,4 @@ -from app.core.legacy_conversation_projection import LegacyConversationProjection +from app.core.conversation_projection import ConversationProjection from app.db.models import ChatSession, Skill from app.session.session_schema import StepAgentResult @@ -13,7 +13,7 @@ def test_dedupe_citations_preserves_first_four_and_relabels() -> None: {"title": "Epsilon"}, ] - assert LegacyConversationProjection.dedupe_knowledge_citations(citations) == [ + assert ConversationProjection.dedupe_knowledge_citations(citations) == [ {"title": " Alpha ", "label": "[1]"}, {"section_path": "Beta", "label": "[2]"}, {"summary": "Gamma", "label": "[3]"}, @@ -39,7 +39,7 @@ def test_skill_state_hides_unavailable_active_and_pending_skills() -> None: ], ) - assert LegacyConversationProjection.skill_state_payload(session, [visible]) == { + assert ConversationProjection.skill_state_payload(session, [visible]) == { "activeSkillId": None, "activeStepId": None, "currentSkills": [ @@ -54,18 +54,19 @@ def test_skill_state_hides_unavailable_active_and_pending_skills() -> None: def test_reply_citation_and_title_projection_preserves_legacy_rules() -> None: - assert LegacyConversationProjection.fallback_session_title(" 请问 A? ") == "请问 A" - assert LegacyConversationProjection.normalize_reply_citation_labels( - "有效[1] 无效[9]", [{}, {}] - ) == "有效[1] 无效[2]" - assert LegacyConversationProjection.strip_trailing_citation_summary( - "正文\n参考资料:[1] [2]" - ) == "正文" + assert ConversationProjection.fallback_session_title(" 请问 A? ") == "请问 A" + assert ( + ConversationProjection.normalize_reply_citation_labels("有效[1] 无效[9]", [{}, {}]) + == "有效[1] 无效[2]" + ) + assert ( + ConversationProjection.strip_trailing_citation_summary("正文\n参考资料:[1] [2]") == "正文" + ) def test_assistant_metadata_uses_injected_citation_deduper() -> None: calls: list[list[dict[str, object]]] = [] - metadata = LegacyConversationProjection.assistant_message_metadata( + metadata = ConversationProjection.assistant_message_metadata( StepAgentResult( knowledge_results=[ { @@ -74,31 +75,26 @@ def test_assistant_metadata_uses_injected_citation_deduper() -> None: } ] ), - citation_deduper=lambda citations: calls.append(citations) - or [{"title": "injected", "label": "[1]"}], + citation_deduper=lambda citations: ( + calls.append(citations) or [{"title": "injected", "label": "[1]"}] + ), ) assert calls - assert metadata["knowledge_citations"] == [ - {"title": "injected", "label": "[1]"} - ] + assert metadata["knowledge_citations"] == [{"title": "injected", "label": "[1]"}] def test_assistant_metadata_uses_latest_knowledge_result_window() -> None: - metadata = LegacyConversationProjection.assistant_message_metadata( + metadata = ConversationProjection.assistant_message_metadata( StepAgentResult( knowledge_results=[ { "query": {"query": "旧问题"}, - "chunks": [ - {"id": "old", "content": "旧答案", "source_ref": "old.md"} - ], + "chunks": [{"id": "old", "content": "旧答案", "source_ref": "old.md"}], }, { "query": {"query": "新问题"}, - "chunks": [ - {"id": "new", "content": "新答案", "source_ref": "new.md"} - ], + "chunks": [{"id": "new", "content": "新答案", "source_ref": "new.md"}], }, ] ) diff --git a/backend/tests/test_general_skill_provider_runtime.py b/backend/tests/test_general_skill_provider_runtime.py index fe563011..9d9a4a5a 100644 --- a/backend/tests/test_general_skill_provider_runtime.py +++ b/backend/tests/test_general_skill_provider_runtime.py @@ -1,14 +1,7 @@ -from dataclasses import FrozenInstanceError, replace - -import pytest - from app.capabilities.local_general_skill import ( - GeneralSkillRuntimeSnapshot, package_from_row, ) -from app.core.agent_loop import AgentLoop, AgentLoopPreconditionError -from app.db.models import ChatSession, GeneralSkill -from app.session.session_schema import ChatTurnRequest +from app.db.models import GeneralSkill class RecordingCatalog: @@ -43,54 +36,6 @@ def _skill() -> GeneralSkill: ) -def test_agent_loop_loads_provider_content_into_one_run_snapshot() -> None: - skill = _skill() - catalog = RecordingCatalog(package_from_row(skill)) - loop = AgentLoop.__new__(AgentLoop) - loop.general_skill_catalog = catalog - session = ChatSession( - id="session_01", - tenant_id="tenant_demo", - user_id="user_01", - agent_id="agent_01", - ) - request = ChatTurnRequest( - tenant_id="tenant_demo", - session_id=session.id, - user_id=session.user_id, - agent_id=session.agent_id, - client_turn_id="turn_01", - channel="feishu", - message="北京天气", - ) - - snapshot = loop._general_skill_runtime_snapshot(request, session, skill) - - assert isinstance(snapshot, GeneralSkillRuntimeSnapshot) - context, resource_ref = catalog.calls[0] - assert ( - context.tenant_id, - context.agent_id, - context.user_id, - context.session_id, - context.turn_id, - context.channel, - ) == ( - "tenant_demo", - "agent_01", - "user_01", - "session_01", - "turn_01", - "feishu", - ) - assert resource_ref.catalog_binding_id == catalog.provider_id - - skill.skill_files_json[0]["content"] = "changed after load" - assert snapshot.skill_files_json[0]["content"] == "# Weather" - with pytest.raises(FrozenInstanceError): - snapshot.skill_markdown = "changed" # type: ignore[misc] - - def test_provider_package_pin_rejects_content_drift() -> None: skill = _skill() first = package_from_row(skill) @@ -98,42 +43,3 @@ def test_provider_package_pin_rejects_content_drift() -> None: second = package_from_row(skill) assert first.digest != second.digest - - -@pytest.mark.parametrize( - ("field", "value"), - [ - ("package_id", "genskill_other"), - ("version", "other-version"), - ("digest", "sha256:other"), - ("package_contract_version", "other-contract"), - ], -) -def test_agent_loop_rejects_provider_package_that_does_not_match_pin( - field: str, - value: str, -) -> None: - skill = _skill() - package = replace(package_from_row(skill), **{field: value}) - loop = AgentLoop.__new__(AgentLoop) - loop.general_skill_catalog = RecordingCatalog(package) - session = ChatSession( - id="session_01", - tenant_id="tenant_demo", - user_id="user_01", - agent_id="agent_01", - ) - request = ChatTurnRequest( - tenant_id="tenant_demo", - session_id=session.id, - user_id=session.user_id, - agent_id=session.agent_id, - client_turn_id="turn_01", - channel="web", - message="北京天气", - ) - - with pytest.raises(AgentLoopPreconditionError) as exc_info: - loop._general_skill_runtime_snapshot(request, session, skill) - - assert exc_info.value.code == "general_skill_content_unavailable" diff --git a/backend/tests/test_general_skills.py b/backend/tests/test_general_skills.py index 5ac2d808..1d6c7e39 100644 --- a/backend/tests/test_general_skills.py +++ b/backend/tests/test_general_skills.py @@ -25,13 +25,9 @@ publish_general_skill_to_gallery, run_general_skill, ) -from app.core import AgentLoop -from app.core.reflection_agent import ReflectionDecision from app.db.models import ( - AgentEvent, AgentProfile, AgentResourceBinding, - ChatSession, GeneralSkill, ModelConfig, Skill, @@ -50,14 +46,10 @@ GeneralSkillPackageUploadRequest, GeneralSkillRunRequest, GeneralSkillRunResponse, - GeneralSkillSelection, ) -from app.harness.errors import HarnessExecutionError from app.llm import LLMClient, LLMError from app.security.auth import hash_password from app.security.encryption import encrypt_secret -from app.session.session_schema import ChatTurnRequest, RouterDecision, StepAgentResult -from app.tools.tool_schema import ToolCall WEATHER_SKILL_MD = """# 中国城市天气查询工具 @@ -117,48 +109,10 @@ def test_runner_accepts_only_explicit_artifact_manifest_files(tmp_path: Path) -> "display_name": "结果.csv", } ] - assert [item["path"] for item in structured["artifact_errors"]] == [ - "../outside.txt" - ] + assert [item["path"] for item in structured["artifact_errors"]] == ["../outside.txt"] assert all(item["path"] != "cache.tmp" for item in structured["artifacts"]) -def test_agent_loop_preserves_structured_sandbox_failure() -> None: - class FailingRunner: - def run(self, *_args, **_kwargs): - raise HarnessExecutionError( - "SANDBOX_POLICY_UNSUPPORTED", - "当前沙盒不支持域名白名单。", - ) - - loop = object.__new__(AgentLoop) - loop.db = SimpleNamespace(get=lambda *_args: None) - loop.general_skill_runner = FailingRunner() - loop.general_skill_reader = SimpleNamespace() - skill = GeneralSkill( - tenant_id="tenant_demo", - slug="sandbox-test", - name="Sandbox Test", - skill_markdown="# Sandbox Test", - status="published", - ) - - response = loop._run_general_skill_operation( - skill, - "run", - SimpleNamespace(), - "user_demo", - ) - - assert response.structured_result == { - "success": False, - "error": "SANDBOX_POLICY_UNSUPPORTED", - "message": "当前沙盒不支持域名白名单。", - "retryable": False, - "infrastructure_failure": True, - } - - def _system_and_stage_instructions(system_prompt: object, payload: object) -> str: stage = payload.get("_agent_stage", {}) if isinstance(payload, dict) else {} instructions = stage.get("instructions", "") if isinstance(stage, dict) else "" @@ -258,9 +212,7 @@ def test_general_skill_reader_returns_structured_failure(monkeypatch) -> None: monkeypatch.setattr( LLMClient, "generate_json", - lambda self, system_prompt, payload: (_ for _ in ()).throw( - LLMError("model unavailable") - ), + lambda self, system_prompt, payload: (_ for _ in ()).throw(LLMError("model unavailable")), ) skill = GeneralSkill( tenant_id="tenant_demo", @@ -282,42 +234,6 @@ def test_general_skill_reader_returns_structured_failure(monkeypatch) -> None: assert any(item["phase"] == "read_failed" for item in response.execution_trace) -def test_capability_knowledge_is_merged_into_general_skill_result() -> None: - step_result = StepAgentResult(reply="天气查询完成", is_step_completed=True) - knowledge_result = StepAgentResult( - knowledge_query={"query": "内部出差规范"}, - knowledge_results=[{"evidence_pack": [{"content": "恶劣天气时应调整行程"}]}], - ) - - AgentLoop._merge_capability_knowledge(step_result, knowledge_result) - - assert step_result.knowledge_query is not None - assert step_result.knowledge_query.query == "内部出差规范" - assert step_result.knowledge_results == knowledge_result.knowledge_results - - -def test_knowledge_keywords_do_not_bypass_structured_capability_selection() -> None: - loop = object.__new__(AgentLoop) - result = loop._auto_knowledge_step_result( # noqa: SLF001 - ChatTurnRequest( - tenant_id="tenant_demo", - user_id="user_demo", - message="请根据知识库资料、规则、政策和文档说明怎么处理", - ), - ChatSession(id="session_demo", tenant_id="tenant_demo"), - SimpleNamespace(), - RouterDecision(decision="answer_only"), - GeneralSkillSelection( - use_general_skill=False, - use_knowledge=False, - reason="第二轮能力选择认为当前上下文足以回答。", - ), - ) - - assert result.knowledge_query is None - assert result.knowledge_results == [] - - def _admin_user() -> User: return User( id="user_admin", @@ -480,58 +396,6 @@ def test_deleted_open_gallery_general_skill_binding_is_not_restored_by_ensure() assert list_general_skills("tenant_demo", db) == [] -def test_deleted_open_gallery_general_skill_is_hidden_from_agent_branch_binding() -> None: - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True - ) - ) - db.add( - AgentProfile( - id="agent_branch", tenant_id="tenant_demo", name="研发员工", is_overall=False - ) - ) - db.commit() - - imported = import_general_skill( - GeneralSkillImportRequest( - tenant_id="tenant_demo", - name="天气技能", - slug="weather-zh", - markdown=WEATHER_SKILL_MD, - ), - db, - _admin_user(), - ) - db.add( - AgentResourceBinding( - tenant_id="tenant_demo", - agent_id="agent_branch", - resource_type="general_skill", - resource_id=imported.id, - status="active", - ) - ) - db.commit() - assert [ - row.id for row in list_general_skills("tenant_demo", db, agent_id="agent_branch") - ] == [imported.id] - - deleted = delete_general_skill( - imported.slug, - "tenant_demo", - db, - agent_id="agent_overall", - current_user=_admin_user(), - ) - assert deleted == {"status": "hidden", "slug": "weather-zh"} - - assert list_general_skills("tenant_demo", db, agent_id="agent_branch") == [] - assert AgentLoop(db)._list_published_general_skills("tenant_demo", "agent_branch") == [] - - def test_reimport_restores_deleted_private_skill_binding() -> None: with _test_session() as db: _seed_minimal_tenant(db) @@ -581,9 +445,9 @@ def test_reimport_restores_deleted_private_skill_binding() -> None: assert restored.id == imported.id assert restored.slug == "weather-zh" assert restored.name == "更新后的天气技能" - assert [row.id for row in list_general_skills("tenant_demo", db, agent_id="agent_branch")] == [ - imported.id - ] + assert [ + row.id for row in list_general_skills("tenant_demo", db, agent_id="agent_branch") + ] == [imported.id] def test_private_skill_can_be_published_to_open_gallery() -> None: @@ -1185,1027 +1049,11 @@ def test_non_overall_agent_delete_hides_general_skill_only_in_branch() -> None: ) -def test_chat_turn_uses_general_skill_after_scene_router_skips_unmatched_scene( - monkeypatch, -) -> None: - monkeypatch.setattr(AgentLoop, "_uses_harness_v2", lambda _self, _request: False) - calls: list[str] = [] - - def fake_init(self, model_config): # noqa: ANN001 - return None - - def fake_generate_json(self, system_prompt, payload): # noqa: ANN001 - prompt_text = _system_and_stage_instructions(system_prompt, payload) - if "企业技能路由器" in prompt_text: - calls.append("router") - return { - "decision": "clarify", - "target_skill_id": "skill_weather_query", - "target_step_id": "step_query_weather", - "confidence": 0.85, - "user_intent": "查询海淀区天气", - "reason": "模型错误地假设存在天气流程。", - } - if "通用技能选择器" in prompt_text: - calls.append("selector") - return { - "use_general_skill": True, - "selected_slug": "weather-zh", - "confidence": 0.96, - "reason": "用户询问天气。", - } - if "通用技能执行器" in prompt_text: - calls.append("runner") - code = ( - "import json\n" - "payload=json.loads(input())\n" - "print(json.dumps({'success': True, 'city': '海淀区', 'weather': '晴', 'query': payload['query']}, ensure_ascii=False))\n" - ) - return {"code": code, "rationale": "天气查询 demo"} - if "通用技能结果回复器" in prompt_text: - calls.append("reply") - assert payload["structured_result"]["weather"] == "晴" - return {"reply": "海淀区今天晴。"} - if "企业技能执行助手" in prompt_text: - raise AssertionError("step agent should not run without an active scene skill") - raise AssertionError("unexpected JSON prompt") - - def fake_generate_text(self, system_prompt, payload): # noqa: ANN001 - calls.append("response") - assert payload["current_step"] is None - assert payload["slots"] == {} - assert payload["tool_result"]["tool_name"] == "general_skill.weather-zh" - assert payload["tool_result"]["success"] is True - assert payload["tool_result"]["data"]["structured_result"]["weather"] == "晴" - assert payload["step_summary"]["reply"] == "海淀区今天晴。" - return "海淀区今天晴。" - - monkeypatch.setattr(LLMClient, "__init__", fake_init) - monkeypatch.setattr(LLMClient, "generate_json", fake_generate_json) - monkeypatch.setattr(LLMClient, "generate_text", fake_generate_text) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True - ) - ) - scene_skill = _purchase_scene_skill() - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - homepage="https://www.weather.com.cn/", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(scene_skill) - db.add(general_skill) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.commit() - - response = AgentLoop(db).handle_turn( - ChatTurnRequest( - tenant_id="tenant_demo", - user_id="user_demo", - message="我想看下海淀区的天气", - ) - ) - - assert response.reply == "海淀区今天晴。" - assert calls == ["router", "selector", "runner", "reply", "response"] - assert response.tool_result is not None - assert response.tool_result.tool_name == "general_skill.weather-zh" - assert response.router_decision is not None - assert response.router_decision.target_skill_id is None - events = db.exec( - select(AgentEvent).where(AgentEvent.session_id == response.session_id) - ).all() - event_types = {event.event_type for event in events} - assert "general_skill_selected" in event_types - assert "tool_call_started" not in event_types - assert "step_agent_result_created" not in event_types - - -def test_general_skill_response_keeps_active_scene_context(monkeypatch) -> None: - monkeypatch.setattr(AgentLoop, "_uses_harness_v2", lambda _self, _request: False) - calls: list[str] = [] - - def fake_init(self, model_config): # noqa: ANN001 - return None - - def fake_generate_json(self, system_prompt, payload): # noqa: ANN001 - prompt_text = _system_and_stage_instructions(system_prompt, payload) - if "企业技能路由器" in prompt_text: - calls.append("router") - return { - "decision": "answer_only", - "confidence": 0.9, - "user_intent": "购买流程中插入天气查询", - "reason": "用户在购买流程中询问天气,需要先回答相关问题。", - } - if "通用技能选择器" in prompt_text: - calls.append("selector") - return { - "use_general_skill": True, - "selected_slug": "weather-zh", - "confidence": 0.96, - "reason": "用户询问海淀天气。", - } - if "通用技能执行器" in prompt_text: - calls.append("runner") - code = ( - "import json\n" - "payload=json.loads(input())\n" - "print(json.dumps({'success': True, 'city': '海淀', 'weather': '晴', 'query': payload['query']}, ensure_ascii=False))\n" - ) - return {"code": code, "rationale": "天气查询 demo"} - if "通用技能结果回复器" in prompt_text: - calls.append("reply") - assert payload["structured_result"]["city"] == "海淀" - return {"reply": "海淀当前天气晴。"} - if "企业技能执行助手" in prompt_text: - raise AssertionError( - "scene step agent should not run for inserted general skill answer" - ) - raise AssertionError("unexpected JSON prompt") - - def fake_generate_text(self, system_prompt, payload): # noqa: ANN001 - calls.append("response") - assert payload["current_step"]["node_id"] == "collect_product" - assert payload["slots"]["user_name"] == "hm" - assert payload["tool_result"]["tool_name"] == "general_skill.weather-zh" - assert payload["tool_result"]["data"]["reply"] == "海淀当前天气晴。" - return "海淀当前天气晴。天气合适的话,请继续告诉我想购买的商品和数量。" - - monkeypatch.setattr(LLMClient, "__init__", fake_init) - monkeypatch.setattr(LLMClient, "generate_json", fake_generate_json) - monkeypatch.setattr(LLMClient, "generate_text", fake_generate_text) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True - ) - ) - scene_skill = _purchase_scene_skill() - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - homepage="https://www.weather.com.cn/", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(scene_skill) - db.add(general_skill) - db.add( - ChatSession( - id="session_weather_inside_purchase", - tenant_id="tenant_demo", - user_id="user_demo", - active_skill_id="purchase", - active_step_id="collect_product", - slots_json={"user_name": "hm"}, - ) - ) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.commit() - - response = AgentLoop(db).handle_turn( - ChatTurnRequest( - tenant_id="tenant_demo", - session_id="session_weather_inside_purchase", - user_id="user_demo", - message="诶?现在海淀天气怎么样,天气好我就出门买了", - ) - ) - - assert response.reply == "海淀当前天气晴。天气合适的话,请继续告诉我想购买的商品和数量。" - assert response.tool_result is not None - assert response.session_state.active_skill_id == "purchase" - assert response.session_state.active_step_id == "collect_product" - assert calls == ["router", "selector", "runner", "reply", "response"] - - -def test_general_skill_and_active_scene_run_in_the_same_turn(monkeypatch) -> None: - selector_calls: list[str] = [] - runner_calls: list[str] = [] - step_calls: list[list[str]] = [] - - with _test_session() as db: - _seed_minimal_tenant(db) - overall_agent = AgentProfile( - id="agent_overall_scene_and_general", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - scene_skill = _purchase_scene_skill() - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - chat_session = ChatSession( - id="session_scene_and_general", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id=overall_agent.id, - active_skill_id="purchase", - active_step_id="collect_product", - slots_json={"product_id": "a1"}, - ) - db.add(overall_agent) - db.add(scene_skill) - db.add(general_skill) - db.add(chat_session) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding( - db, "tenant_demo", "general_skill", general_skill.id, "active" - ) - db.commit() - - loop = AgentLoop(db) - - def fake_select( # noqa: ANN001 - query, - general_skills, - model_config, - conversation_context=None, - memory_context=None, - ): - selector_calls.append(query) - return GeneralSkillSelection( - use_general_skill=True, - selected_slug="weather-zh", - confidence=0.98, - reason="用户同时要求查询北京天气。", - ) - - def fake_general_run( # noqa: ANN001 - skill, - query, - model_config, - user_id="", - max_attempts=10, - event_sink=None, - conversation_context=None, - memory_context=None, - ): - runner_calls.append(query) - return GeneralSkillRunResponse( - skill_slug=skill.slug, - execution_trace=[], - generated_code="", - stdout="", - stderr="", - structured_result={"success": True, "city": "北京", "weather": "晴"}, - reply="北京当前天气晴。", - ) - - def fake_step_run(**kwargs): # noqa: ANN003 - step_calls.append([tool.name for tool in kwargs["tools"]]) - assert kwargs["repair_context"]["reason"] == "tool_continuation" - assert kwargs["repair_context"]["previous_tool_result"]["success"] is True - return StepAgentResult( - action="ask_user", - reply="北京当前天气晴。请问您想购买多少数量的 a1?", - slot_updates={"product_id": "a1"}, - ) - - monkeypatch.setattr(loop.general_skill_selector, "decide", fake_select) - monkeypatch.setattr(loop.general_skill_runner, "run", fake_general_run) - monkeypatch.setattr(loop.step_agent, "run", fake_step_run) - - model_config = db.exec( - select(ModelConfig).where(ModelConfig.tenant_id == "tenant_demo") - ).first() - request = ChatTurnRequest( - tenant_id="tenant_demo", - session_id=chat_session.id, - user_id="user_demo", - message="我想买 a1,同时帮我看下北京天气", - ) - router_decision = RouterDecision( - decision="continue_active", - target_skill_id="purchase", - user_intent="购买 a1 并查询北京天气", - ) - tools = loop._tools_with_general_skills( - "tenant_demo", [], overall_agent.id - ) - - initial_result = loop._run_step_agent_with_context_repair( - request, - chat_session, - scene_skill, - tools, - model_config, - router_decision, - [], - {"messages": [{"role": "user", "content": request.message}]}, - [], - ) - final_result, tool_result = loop._execute_tool_action_cycle( - request, - chat_session, - scene_skill, - tools, - model_config, - initial_result, - conversation_context={ - "messages": [{"role": "user", "content": request.message}] - }, - memory_context=[], - ) - - assert initial_result.tool_call == ToolCall( - name="general_skill.weather-zh", - arguments={ - "query": "我想买 a1,同时帮我看下北京天气", - "operation": "execute", - }, - ) - assert tool_result is not None and tool_result.success is True - assert final_result.reply == "北京当前天气晴。请问您想购买多少数量的 a1?" - assert chat_session.active_skill_id == "purchase" - assert chat_session.active_step_id == "collect_product" - assert selector_calls == ["我想买 a1,同时帮我看下北京天气"] - assert runner_calls == ["我想买 a1,同时帮我看下北京天气"] - assert step_calls == [[]] - event_types = { - event.event_type - for event in db.exec( - select(AgentEvent).where(AgentEvent.session_id == chat_session.id) - ).all() - } - assert "general_skill_selected" in event_types - assert "tool_call_finished" in event_types - - -def test_scene_general_skill_read_does_not_mutate_sop_state(monkeypatch) -> None: - with _test_session() as db: - _seed_minimal_tenant(db) - overall_agent = AgentProfile( - id="agent_overall_scene_read", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - scene_skill = _purchase_scene_skill() - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - original_slots = {"product_id": "a1"} - chat_session = ChatSession( - id="session_scene_read", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id=overall_agent.id, - active_skill_id="purchase", - active_step_id="collect_product", - slots_json=dict(original_slots), - ) - db.add(overall_agent) - db.add(scene_skill) - db.add(general_skill) - db.add(chat_session) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding( - db, "tenant_demo", "general_skill", general_skill.id, "active" - ) - db.commit() - - loop = AgentLoop(db) - monkeypatch.setattr( - loop.general_skill_selector, - "decide", - lambda *args, **kwargs: GeneralSkillSelection( - use_general_skill=True, - selected_slug="weather-zh", - operation="read", - confidence=0.99, - reason="用户只想了解 Skill。", - ), - ) - monkeypatch.setattr( - loop.general_skill_reader, - "read", - lambda *args, **kwargs: GeneralSkillRunResponse( - skill_slug="weather-zh", - operation="read", - execution_trace=[{"phase": "read_created", "message": "已读取"}], - structured_result={"success": True, "operation": "read"}, - reply="这是一个天气查询 Skill。", - ), - ) - monkeypatch.setattr( - loop.step_agent, - "run", - lambda **kwargs: (_ for _ in ()).throw( - AssertionError("read must not continue through the SOP step agent") - ), - ) - model_config = db.exec( - select(ModelConfig).where(ModelConfig.tenant_id == "tenant_demo") - ).first() - request = ChatTurnRequest( - tenant_id="tenant_demo", - session_id=chat_session.id, - user_id="user_demo", - message="介绍一下天气 Skill,不要执行", - ) - tools = loop._tools_with_general_skills("tenant_demo", [], overall_agent.id) - - initial_result = loop._run_step_agent_with_context_repair( - request, - chat_session, - scene_skill, - tools, - model_config, - RouterDecision(decision="continue_active", target_skill_id="purchase"), - ) - final_result, tool_result = loop._execute_tool_action_cycle( - request, - chat_session, - scene_skill, - tools, - model_config, - initial_result, - ) - - assert tool_result is not None and tool_result.success is True - assert tool_result.data["operation"] == "read" - assert final_result.reply == "这是一个天气查询 Skill。" - assert final_result.is_step_completed is False - assert chat_session.active_skill_id == "purchase" - assert chat_session.active_step_id == "collect_product" - assert chat_session.slots_json == original_slots - - -def test_knowledge_query_step_still_runs_general_skill_selector(monkeypatch) -> None: - with _test_session() as db: - _seed_minimal_tenant(db) - overall_agent = AgentProfile( - id="agent_overall_knowledge_selector", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - scene_skill = _purchase_scene_skill() - scene_skill.content_json["steps"][0]["type"] = "knowledge_query" - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - chat_session = ChatSession( - id="session_knowledge_selector", - tenant_id="tenant_demo", - user_id="user_demo", - agent_id=overall_agent.id, - active_skill_id="purchase", - active_step_id="collect_product", - ) - db.add(overall_agent) - db.add(scene_skill) - db.add(general_skill) - db.add(chat_session) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding( - db, "tenant_demo", "general_skill", general_skill.id, "active" - ) - db.commit() - loop = AgentLoop(db) - selector_queries: list[str] = [] - - def fake_select(query, *args, **kwargs): # noqa: ANN001 - selector_queries.append(query) - return GeneralSkillSelection( - use_general_skill=True, - selected_slug="weather-zh", - confidence=0.9, - ) - - monkeypatch.setattr(loop.general_skill_selector, "decide", fake_select) - model_config = db.exec( - select(ModelConfig).where(ModelConfig.tenant_id == "tenant_demo") - ).first() - request = ChatTurnRequest( - tenant_id="tenant_demo", - user_id="user_demo", - message="查一下北京天气", - ) - - result = loop._run_step_agent_with_context_repair( - request, - chat_session, - scene_skill, - loop._tools_with_general_skills("tenant_demo", [], overall_agent.id), - model_config, - RouterDecision(decision="continue_active", target_skill_id="purchase"), - ) - - assert selector_queries == ["查一下北京天气"] - assert result.tool_call is not None - assert result.tool_call.name == "general_skill.weather-zh" - - -def test_general_skill_tool_rejects_explicit_invalid_operation() -> None: - loop = object.__new__(AgentLoop) - result = loop._execute_general_skill_tool_call( - ChatTurnRequest( - tenant_id="tenant_demo", - user_id="user_demo", - message="介绍天气 Skill", - ), - ChatSession(id="session_invalid_operation", tenant_id="tenant_demo"), - ToolCall( - name="general_skill.weather-zh", - arguments={"query": "介绍天气 Skill", "operation": "inspect"}, - ), - None, - ) - - assert result.success is False - assert result.error is not None - assert result.error.code == "INVALID_GENERAL_SKILL_OPERATION" - - -def test_scene_tool_call_to_general_skill_records_expandable_trace(monkeypatch) -> None: - received_contexts: list[object] = [] - - def fake_decide( # noqa: ANN001 - self, - query, - general_skills, - model_config, - conversation_context=None, - memory_context=None, - ): - received_contexts.append(conversation_context) - return GeneralSkillSelection( - use_general_skill=True, - selected_slug="weather-zh", - confidence=0.95, - reason="天气查询与天气技能匹配。", - ) - - def fake_run( # noqa: ANN001 - self, - skill, - query, - model_config, - user_id="", - max_attempts=10, - event_sink=None, - conversation_context=None, - memory_context=None, - ): - received_contexts.append(conversation_context) - trace = [ - {"phase": "skill_loaded", "message": "已加载通用技能 中国城市天气", "slug": skill.slug}, - { - "phase": "plan_created", - "message": "已生成 Python runner", - "runtime": "python", - "code": "import json\nprint(json.dumps({'success': True, 'city': '北京'}, ensure_ascii=False))\n", - "rationale": "查询天气。", - }, - {"phase": "attempt_started", "message": "开始第 1 次运行", "attempt": 1}, - {"phase": "stdout_chunk", "text": '{"success": true, "city": "北京"}'}, - {"phase": "reflection_passed", "message": "第 1 次运行结果可用", "attempt": 1}, - {"phase": "reply_created", "message": "已生成最终回复"}, - ] - if event_sink: - for item in trace: - event_sink(item) - return GeneralSkillRunResponse( - skill_slug=skill.slug, - execution_trace=trace, - generated_code=trace[1]["code"], - stdout='{"success": true, "city": "北京"}', - stderr="", - structured_result={"success": True, "city": "北京"}, - reply="北京天气已查询。", - ) - - monkeypatch.setattr(GeneralSkillSelector, "decide", fake_decide) - monkeypatch.setattr(GeneralSkillRunner, "run", fake_run) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall_tool_trace", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - ) - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(general_skill) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.add( - ChatSession( - id="session_general_skill_tool", - tenant_id="tenant_demo", - user_id="user_demo", - active_skill_id="purchase", - active_step_id="collect_product", - ) - ) - db.commit() - - stream_events: list[tuple[str, dict[str, object]]] = [] - conversation_context = {"messages": [{"role": "user", "content": "北京天气怎么样"}]} - result = AgentLoop(db)._execute_tool_call( - ChatTurnRequest( - tenant_id="tenant_demo", - session_id="session_general_skill_tool", - user_id="user_demo", - message="北京天气怎么样", - ), - db.get(ChatSession, "session_general_skill_tool"), - ToolCall(name="general_skill.weather-zh", arguments={"query": "北京天气怎么样"}), - tool_call_id="toolcall_weather", - stream_events=stream_events, - conversation_context=conversation_context, - ) - - assert result.success is True - assert result.data["generated_code"].startswith("import json") - rows = db.exec( - select(AgentEvent).where(AgentEvent.session_id == "session_general_skill_tool") - ).all() - event_types = [row.event_type for row in rows] - assert event_types[0] == "tool_call_started" - assert "general_skill_trace" in event_types - assert "general_skill_run_finished" in event_types - assert event_types[-1] == "tool_call_finished" - trace_payloads = [ - row.payload_json for row in rows if row.event_type == "general_skill_trace" - ] - assert any( - payload.get("phase") == "plan_created" and "import json" in str(payload.get("code")) - for payload in trace_payloads - ) - assert any(payload.get("phase") == "stdout_chunk" for payload in trace_payloads) - assert [name for name, _payload in stream_events].count("general_skill_trace") == len( - trace_payloads - ) - assert any(name == "general_skill_run_finished" for name, _payload in stream_events) - assert received_contexts == [conversation_context, conversation_context] - - -def test_scene_tool_call_to_general_skill_backfills_returned_trace(monkeypatch) -> None: - def fake_decide( # noqa: ANN001 - self, - query, - general_skills, - model_config, - conversation_context=None, - memory_context=None, - ): - return GeneralSkillSelection( - use_general_skill=True, - selected_slug="weather-zh", - confidence=0.95, - reason="天气查询与天气技能匹配。", - ) - - def fake_run( # noqa: ANN001 - self, - skill, - query, - model_config, - user_id="", - max_attempts=10, - event_sink=None, - conversation_context=None, - memory_context=None, - ): - trace = [ - {"phase": "skill_loaded", "message": "已加载通用技能 中国城市天气", "slug": skill.slug}, - { - "phase": "plan_created", - "message": "已生成 Python runner", - "runtime": "python", - "code": "print('ok')\n", - }, - {"phase": "stdout_chunk", "text": "ok"}, - {"phase": "reply_created", "message": "已生成最终回复"}, - ] - return GeneralSkillRunResponse( - skill_slug=skill.slug, - execution_trace=trace, - generated_code=trace[1]["code"], - stdout="ok", - stderr="", - structured_result={"success": True}, - reply="北京天气已查询。", - ) - - monkeypatch.setattr(GeneralSkillSelector, "decide", fake_decide) - monkeypatch.setattr(GeneralSkillRunner, "run", fake_run) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall_tool_backfill", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - ) - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(general_skill) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.add( - ChatSession( - id="session_general_skill_tool_backfill", - tenant_id="tenant_demo", - user_id="user_demo", - active_skill_id="purchase", - active_step_id="collect_product", - ) - ) - db.commit() - - stream_events: list[tuple[str, dict[str, object]]] = [] - result = AgentLoop(db)._execute_tool_call( - ChatTurnRequest( - tenant_id="tenant_demo", - session_id="session_general_skill_tool_backfill", - user_id="user_demo", - message="北京天气怎么样", - ), - db.get(ChatSession, "session_general_skill_tool_backfill"), - ToolCall(name="general_skill.weather-zh", arguments={"query": "北京天气怎么样"}), - tool_call_id="toolcall_weather_backfill", - stream_events=stream_events, - ) - - assert result.success is True - rows = db.exec( - select(AgentEvent).where(AgentEvent.session_id == "session_general_skill_tool_backfill") - ).all() - trace_payloads = [ - row.payload_json for row in rows if row.event_type == "general_skill_trace" - ] - assert [payload.get("phase") for payload in trace_payloads] == [ - "skill_loaded", - "plan_created", - "stdout_chunk", - "reply_created", - ] - assert [name for name, _payload in stream_events].count("general_skill_trace") == len( - trace_payloads - ) - - -def test_scene_tool_call_rejects_mismatched_general_skill(monkeypatch) -> None: - runner_calls: list[str] = [] - - def fake_decide( # noqa: ANN001 - self, - query, - general_skills, - model_config, - conversation_context=None, - memory_context=None, - ): - return GeneralSkillSelection( - use_general_skill=False, - selected_slug=None, - confidence=0.12, - reason="商品价格查询不属于候选通用技能能力。", - ) - - def fake_run( # noqa: ANN001 - self, - skill, - query, - model_config, - user_id="", - max_attempts=10, - event_sink=None, - conversation_context=None, - memory_context=None, - ): - runner_calls.append(query) - return GeneralSkillRunResponse( - skill_slug=skill.slug, - execution_trace=[], - generated_code="", - stdout="", - stderr="", - structured_result={"success": True}, - reply="不应执行。", - ) - - monkeypatch.setattr(GeneralSkillSelector, "decide", fake_decide) - monkeypatch.setattr(GeneralSkillRunner, "run", fake_run) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall_tool_mismatch", - tenant_id="tenant_demo", - name="开放广场", - is_overall=True, - ) - ) - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(general_skill) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.add( - ChatSession( - id="session_general_skill_mismatch", - tenant_id="tenant_demo", - user_id="user_demo", - active_skill_id="purchase", - active_step_id="collect_product", - ) - ) - db.commit() - - result = AgentLoop(db)._execute_tool_call( - ChatTurnRequest( - tenant_id="tenant_demo", - session_id="session_general_skill_mismatch", - user_id="user_demo", - message="查询商品 A1 和 A3 的当前实时价格", - ), - db.get(ChatSession, "session_general_skill_mismatch"), - ToolCall( - name="general_skill.weather-zh", - arguments={"query": "查询商品 A1 和 A3 的当前实时价格"}, - ), - tool_call_id="toolcall_weather_mismatch", - ) - - assert result.success is False - assert result.error is not None - assert result.error.code == "GENERAL_SKILL_MISMATCH" - assert runner_calls == [] - rows = db.exec( - select(AgentEvent).where(AgentEvent.session_id == "session_general_skill_mismatch") - ).all() - assert any(row.event_type == "general_skill_guard_rejected" for row in rows) - - -def test_scene_step_agent_does_not_expose_irrelevant_general_skill(monkeypatch) -> None: - def fake_decide( # noqa: ANN001 - self, - query, - general_skills, - model_config, - conversation_context=None, - memory_context=None, - ): - return GeneralSkillSelection( - use_general_skill=False, - selected_slug=None, - confidence=0.08, - reason="商品价格查询不属于天气通用技能。", - ) - - monkeypatch.setattr(GeneralSkillSelector, "decide", fake_decide) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - ) - db.commit() - - model_config = db.exec( - select(ModelConfig).where(ModelConfig.tenant_id == "tenant_demo") - ).first() - active_skill = Skill( - tenant_id="tenant_demo", - skill_id="after_sales_refund", - name="售后退款流程", - status="published", - content_json={}, - ) - tools = [ - SimpleNamespace( - enabled=True, name="order.query", allowed_skills_json=["after_sales_refund"] - ), - SimpleNamespace(enabled=True, name="general_skill.weather-zh", allowed_skills_json=[]), - ] - - scoped = AgentLoop(db)._step_agent_tools( - active_skill, - tools, - "查询商品 'a' 的价格", - model_config, - ) - - assert scoped == [] - - -def test_reflection_can_retry_general_skill_with_user_query() -> None: - loop = AgentLoop.__new__(AgentLoop) - tool_call = loop._tool_call_from_reflection( - ReflectionDecision( - action="retry_tool", - needs_retry=True, - target_tool_name="general_skill.weather-zh", - reason="场景内临时查询需要通用技能执行。", - ), - ChatSession( - id="session_reflection_general_skill", - tenant_id="tenant_demo", - user_id="user_demo", - active_skill_id="skill_purchase_001", - active_step_id="collect_user_name", - slots_json={"user_name": "hm", "product_id": "A1"}, - ), - [ - SimpleNamespace( - enabled=True, - name="general_skill.weather-zh", - input_schema={ - "type": "object", - "properties": {"query": {"type": "string"}}, - "required": ["query"], - }, - ) - ], - "我想买个 A1,同时查一下海淀天气", - ) - - assert tool_call == ToolCall( - name="general_skill.weather-zh", - arguments={"query": "我想买个 A1,同时查一下海淀天气"}, - ) - - def test_scene_layer_prompt_contract_mentions_general_skill_tools() -> None: prompt_dir = Path(__file__).resolve().parents[1] / "app" / "llm" / "prompts" router_prompt = (prompt_dir / "router_prompt.md").read_text(encoding="utf-8") - step_prompt = (prompt_dir / "step_agent_general_skill_rules.md").read_text( - encoding="utf-8" - ) + step_prompt = (prompt_dir / "step_agent_general_skill_rules.md").read_text(encoding="utf-8") reflection_prompt = (prompt_dir / "reflection_prompt.md").read_text(encoding="utf-8") assert "Router 只决定场景化技能和任务执行顺序" in router_prompt @@ -2213,92 +1061,6 @@ def test_scene_layer_prompt_contract_mentions_general_skill_tools() -> None: assert "target_tool_name 指向该通用技能工具" in reflection_prompt -def test_chat_turn_treats_unmatched_scene_as_chat_when_general_skill_not_selected( - monkeypatch, -) -> None: - monkeypatch.setattr(AgentLoop, "_uses_harness_v2", lambda _self, _request: False) - calls: list[str] = [] - - def fake_init(self, model_config): # noqa: ANN001 - return None - - def fake_generate_json(self, system_prompt, payload): # noqa: ANN001 - prompt_text = _system_and_stage_instructions(system_prompt, payload) - if "企业技能路由器" in prompt_text: - calls.append("router") - return { - "decision": "answer_only", - "confidence": 0.95, - "user_intent": "普通闲聊", - "reason": "用户没有匹配任何业务流程。", - } - if "通用技能选择器" in prompt_text: - calls.append("selector") - return { - "use_general_skill": False, - "selected_slug": None, - "confidence": 0.2, - "reason": "没有匹配的通用技能。", - } - if "企业技能执行助手" in prompt_text: - raise AssertionError("step agent should not run without an active scene skill") - raise AssertionError("unexpected JSON prompt") - - def fake_generate_text(self, system_prompt, payload): # noqa: ANN001 - calls.append("response") - assert payload["current_step"] is None - assert "active_skill" not in payload - assert "router_decision" not in payload - assert payload["tool_result"] is None - return "你好,有什么业务需要我帮忙?" - - monkeypatch.setattr(LLMClient, "__init__", fake_init) - monkeypatch.setattr(LLMClient, "generate_json", fake_generate_json) - monkeypatch.setattr(LLMClient, "generate_text", fake_generate_text) - - with _test_session() as db: - _seed_minimal_tenant(db) - db.add( - AgentProfile( - id="agent_overall", tenant_id="tenant_demo", name="整体智能体", is_overall=True - ) - ) - scene_skill = _purchase_scene_skill() - general_skill = GeneralSkill( - tenant_id="tenant_demo", - slug="weather-zh", - name="中国城市天气", - description="中国城市天气查询工具", - homepage="https://www.weather.com.cn/", - skill_markdown=WEATHER_SKILL_MD, - status="published", - ) - db.add(scene_skill) - db.add(general_skill) - db.flush() - ensure_open_gallery_binding(db, "tenant_demo", "skill", scene_skill.id, "active") - ensure_open_gallery_binding(db, "tenant_demo", "general_skill", general_skill.id, "active") - db.commit() - - response = AgentLoop(db).handle_turn( - ChatTurnRequest( - tenant_id="tenant_demo", - user_id="user_demo", - message="你好", - ) - ) - - assert response.reply == "你好,有什么业务需要我帮忙?" - assert calls == ["router", "selector", "response"] - events = db.exec( - select(AgentEvent).where(AgentEvent.session_id == response.session_id) - ).all() - event_types = {event.event_type for event in events} - assert "general_skill_selected" not in event_types - assert "tool_call_started" not in event_types - assert "step_agent_result_created" not in event_types - - def test_general_skill_runner_repairs_failed_code(monkeypatch) -> None: calls: list[str] = [] diff --git a/backend/tests/test_legacy_graph_rules.py b/backend/tests/test_graph_rules.py similarity index 60% rename from backend/tests/test_legacy_graph_rules.py rename to backend/tests/test_graph_rules.py index 00358552..89e523ac 100644 --- a/backend/tests/test_legacy_graph_rules.py +++ b/backend/tests/test_graph_rules.py @@ -1,4 +1,4 @@ -from app.core.legacy_graph_rules import LegacyGraphRules +from app.core.graph_rules import GraphRules def _graph() -> dict: @@ -13,8 +13,18 @@ def _graph() -> dict: {"node_id": "check_payee", "allowed_actions": ["continue_flow"]}, ], "edges": [ - {"source_node_id": "start", "next_node_id": "check_sensitive", "condition": "ready", "priority": 1}, - {"source_node_id": "start", "next_node_id": "check_payee", "condition": "ready", "priority": 0}, + { + "source_node_id": "start", + "next_node_id": "check_sensitive", + "condition": "ready", + "priority": 1, + }, + { + "source_node_id": "start", + "next_node_id": "check_payee", + "condition": "ready", + "priority": 0, + }, {"source_node_id": "check_payee", "next_node_id": "report", "priority": 0}, {"source_node_id": "check_sensitive", "next_node_id": "report", "priority": 0}, ], @@ -24,19 +34,17 @@ def _graph() -> dict: def test_graph_runtime_preserves_legacy_order_and_parallel_siblings() -> None: content = _graph() - assert [node["node_id"] for node in LegacyGraphRules.ordered_nodes(content)] == [ + assert [node["node_id"] for node in GraphRules.ordered_nodes(content)] == [ "start", "check_payee", "report", "check_sensitive", ] - assert [step["step_id"] for step in LegacyGraphRules.next_steps(content, "start")] == [ + assert [step["step_id"] for step in GraphRules.next_steps(content, "start")] == [ "check_payee", "check_sensitive", ] - assert LegacyGraphRules.sibling_steps(content, "start", "check_payee") == [ - "check_sensitive" - ] + assert GraphRules.sibling_steps(content, "start", "check_payee") == ["check_sensitive"] def test_graph_runtime_keeps_exclusive_conditions_out_of_pending_siblings() -> None: @@ -44,32 +52,30 @@ def test_graph_runtime_keeps_exclusive_conditions_out_of_pending_siblings() -> N content["edges"][0]["condition"] = "sensitive" content["edges"][1]["condition"] = "payee" - assert LegacyGraphRules.sibling_steps(content, "start", "check_payee") == [] + assert GraphRules.sibling_steps(content, "start", "check_payee") == [] def test_graph_runtime_normalizes_pending_without_reordering() -> None: - assert LegacyGraphRules.normalize_pending_steps( + assert GraphRules.normalize_pending_steps( [" check_sensitive ", "report", "check_sensitive", "", None] ) == ["check_sensitive", "report"] def test_graph_runtime_default_next_step_matches_legacy_rules() -> None: content = _graph() - assert LegacyGraphRules.default_next_step(content, "check_payee")["step_id"] == "report" - assert LegacyGraphRules.default_next_step(content, "start") is None + assert GraphRules.default_next_step(content, "check_payee")["step_id"] == "report" + assert GraphRules.default_next_step(content, "start") is None content["edges"][0]["condition"] = "else" - assert LegacyGraphRules.default_next_step(content, "start")["step_id"] == "check_sensitive" + assert GraphRules.default_next_step(content, "start")["step_id"] == "check_sensitive" def test_graph_runtime_terminal_position_preserves_legacy_slot_semantics() -> None: content = _graph() - assert not LegacyGraphRules.terminal_position(content, "report", {}) - assert LegacyGraphRules.terminal_position(content, "report", {"message_content": []}) - assert LegacyGraphRules.terminal_position( - content, "report", {"message_content": "Golden report"} - ) + assert not GraphRules.terminal_position(content, "report", {}) + assert GraphRules.terminal_position(content, "report", {"message_content": []}) + assert GraphRules.terminal_position(content, "report", {"message_content": "Golden report"}) def test_graph_runtime_legacy_node_defaults_and_dangling_edges() -> None: @@ -82,14 +88,12 @@ def test_graph_runtime_legacy_node_defaults_and_dangling_edges() -> None: ], } - assert [node["node_id"] for node in LegacyGraphRules.ordered_nodes(content)] == [ + assert [node["node_id"] for node in GraphRules.ordered_nodes(content)] == [ "start", "end", ] - assert [step["step_id"] for step in LegacyGraphRules.next_steps(content, "start")] == [ - "end" - ] - assert LegacyGraphRules.current_step(content, "end") == { + assert [step["step_id"] for step in GraphRules.next_steps(content, "start")] == ["end"] + assert GraphRules.current_step(content, "end") == { "step_id": "end", "node_id": "end", "type": None, diff --git a/backend/tests/test_harness_v2.py b/backend/tests/test_harness_v2.py index 02d23bd1..2a141c17 100644 --- a/backend/tests/test_harness_v2.py +++ b/backend/tests/test_harness_v2.py @@ -103,29 +103,38 @@ def test_first_harness_turn_derives_a_recoverable_session_id() -> None: assert request.session_id is None -def test_every_turn_always_selects_harness_v2() -> None: +def test_agent_loop_has_no_legacy_runtime_switch(monkeypatch) -> None: + calls: list[tuple[str, str]] = [] + + def fake_run(self, request): # noqa: ANN001 + calls.append((request.channel, request.interaction_mode)) + return request.message + + monkeypatch.setattr(HarnessV2Engine, "run", fake_run) + monkeypatch.setattr(HarnessV2Engine, "close", lambda self: None) engine = _test_engine() with Session(engine) as db: loop = AgentLoop(db) - assert loop._uses_harness_v2( + assert not hasattr(loop, "_uses_harness_v2") + assert loop.handle_turn( ChatTurnRequest( tenant_id="tenant-demo", - agent_id="legacy-or-missing-agent", message="普通对话", channel="web", interaction_mode="normal", ) - ) is True - assert loop._uses_harness_v2( + ) == "普通对话" + assert loop.handle_turn( ChatTurnRequest( tenant_id="tenant-demo", - agent_id="legacy-or-missing-agent", message="执行自动任务", channel="scheduled_task", interaction_mode="scheduled_task", ) - ) is True + ) == "执行自动任务" + + assert calls == [("web", "normal"), ("scheduled_task", "scheduled_task")] def test_first_harness_turn_recovers_from_a_concurrent_session_insert( diff --git a/backend/tests/test_legacy_general_skill_action.py b/backend/tests/test_legacy_general_skill_action.py deleted file mode 100644 index 8369412d..00000000 --- a/backend/tests/test_legacy_general_skill_action.py +++ /dev/null @@ -1,124 +0,0 @@ -import pytest - -from app.core.legacy_general_skill_action import LegacyGeneralSkillAction -from app.db.models import ChatSession, GeneralSkill, ModelConfig -from app.general_skills.schema import GeneralSkillRunResponse -from app.session.session_schema import ChatTurnRequest -from app.tools.tool_schema import ToolCall, ToolResult - - -class Events: - def __init__(self) -> None: - self.records: list[tuple[str, str, str, dict[str, object]]] = [] - - def record( - self, tenant_id: str, session_id: str, event_type: str, payload: dict[str, object] - ) -> None: - self.records.append((tenant_id, session_id, event_type, payload)) - - -def _request() -> ChatTurnRequest: - return ChatTurnRequest(tenant_id="tenant", user_id="user", message="run it") - - -def _session() -> ChatSession: - return ChatSession(id="session", tenant_id="tenant") - - -def _skill() -> GeneralSkill: - return GeneralSkill( - tenant_id="tenant", - slug="weather", - name="Weather", - version="1.0.0", - status="published", - ) - - -def _model() -> ModelConfig: - return ModelConfig(tenant_id="tenant", name="model", model="test") - - -def _execute( - *, - tool_name: str = "general_skill.weather", - skills: list[GeneralSkill] | None = None, - model_resolver=None, - runner=None, -) -> ToolResult: - return LegacyGeneralSkillAction(Events()).execute_tool_call( - _request(), - _session(), - ToolCall(name=tool_name, arguments={"query": "weather"}), - None, - None, - None, - None, - tool_prefix="general_skill.", - list_skills=lambda tenant_id, agent_id: skills if skills is not None else [_skill()], - model_resolver=model_resolver or (lambda request, agent_id: _model()), - precondition_error_type=ValueError, - validator=lambda *args: None, - runner=runner - or ( - lambda *args, **kwargs: GeneralSkillRunResponse( - skill_slug="weather", reply="ok", structured_result={"success": True} - ) - ), - ) - - -@pytest.mark.parametrize( - ("tool_name", "skills", "code"), - [ - ("general_skill.", [_skill()], "INVALID_GENERAL_SKILL"), - ("general_skill.missing", [], "GENERAL_SKILL_NOT_FOUND"), - ], -) -def test_general_skill_lookup_failures( - tool_name: str, skills: list[GeneralSkill], code: str -) -> None: - result = _execute(tool_name=tool_name, skills=skills) - - assert result.success is False - assert result.error is not None - assert result.error.code == code - - -def test_general_skill_model_precondition_is_preserved() -> None: - error = ValueError("disabled") - error.code = "disabled_model" # type: ignore[attr-defined] - error.message = "模型已停用" # type: ignore[attr-defined] - - result = _execute(model_resolver=lambda request, agent_id: (_ for _ in ()).throw(error)) - - assert result.error is not None - assert result.error.code == "DISABLED_MODEL" - assert result.error.message == "模型已停用" - - -def test_general_skill_runner_exception_isolated() -> None: - result = _execute( - runner=lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("broken")) - ) - - assert result.error is not None - assert result.error.code == "GENERAL_SKILL_EXECUTION_ERROR" - assert result.error.message == "broken" - - -def test_general_skill_structured_failure_keeps_data() -> None: - result = _execute( - runner=lambda *args, **kwargs: GeneralSkillRunResponse( - skill_slug="weather", - reply="failed", - stderr="details", - structured_result={"success": False, "error": "REMOTE_FAILED"}, - ) - ) - - assert result.success is False - assert result.data is not None - assert result.error is not None - assert result.error.code == "REMOTE_FAILED" - assert result.error.message == "failed" diff --git a/backend/tests/test_reflection_agent_loop.py b/backend/tests/test_reflection_agent_loop.py deleted file mode 100644 index a200143a..00000000 --- a/backend/tests/test_reflection_agent_loop.py +++ /dev/null @@ -1,631 +0,0 @@ -from app.core.agent_loop import AgentLoop -from app.core.reflection_agent import ReflectionDecision -from app.core.skill_runtime import SkillRuntime -from app.db.models import ChatSession, ModelConfig, Skill, Tool -from app.session.session_schema import ( - ChatTurnRequest, - KnowledgeQuery, - RouterDecision, - StepAgentResult, - ToolCall, -) -from app.tools.tool_schema import ToolResult - - -def test_reflection_switches_wrong_active_skill_without_suspending() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="visitor_badge", - active_step_id="collect_visitor", - ) - - decision = loop._router_decision_from_reflection( - ReflectionDecision( - needs_retry=True, - reason="用户要报修,不是办理访客证。", - target_skill_id="repair_ticket", - ), - session, - [_skill("visitor_badge"), _skill("repair_ticket")], - previous_decision=RouterDecision(decision="continue_active"), - ) - - assert decision is not None - assert decision.decision == "start_new_task" - assert decision.target_skill_id == "repair_ticket" - - -def test_reflection_does_not_restart_completed_skill_in_same_turn() -> None: - loop = object.__new__(AgentLoop) - loop.events = _FakeEvents() - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id=None, - active_step_id=None, - ) - - decision = loop._router_decision_from_reflection( - ReflectionDecision( - needs_retry=True, - reason="同一轮已经完成过该技能,不应再次启动。", - target_skill_id="price_compare", - ), - session, - [_skill("price_compare")], - previous_decision=RouterDecision(decision="answer_only"), - completed_skill_ids_this_turn={"price_compare"}, - ) - - assert decision is None - assert any( - record[2] == "reflection_retry_skipped_completed_task" - and record[3]["target_skill_id"] == "price_compare" - for record in loop.events.records - ) - - -def test_reflection_can_continue_completed_skill_when_still_active() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="price_compare", - active_step_id="start", - ) - - decision = loop._router_decision_from_reflection( - ReflectionDecision( - needs_retry=True, - reason="当前 active skill 需要继续修正。", - target_skill_id="price_compare", - ), - session, - [_skill("price_compare")], - previous_decision=RouterDecision(decision="continue_active"), - completed_skill_ids_this_turn={"price_compare"}, - ) - - assert decision is not None - assert decision.decision == "continue_active" - assert decision.target_skill_id == "price_compare" - - -def test_reflection_builds_tool_call_from_slots() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="repair_ticket", - slots_json={"customer_name": "张三", "asset_id": "EQ-9", "issue": "无法启动"}, - ) - - tool_call = loop._tool_call_from_reflection( - ReflectionDecision(needs_retry=True, target_tool_name="ticket.create"), - session, - [_ticket_tool()], - ) - - assert tool_call is not None - assert tool_call.name == "ticket.create" - assert tool_call.arguments["customer_name"] == "张三" - assert tool_call.arguments["asset_id"] == "EQ-9" - assert tool_call.arguments["issue"] == "无法启动" - - -def test_reflection_builds_archive_order_tool_call_from_order_slot() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="after_sales_refund", - slots_json={"order_id": "ARCHIVE-1001"}, - ) - - tool_call = loop._tool_call_from_reflection( - ReflectionDecision(needs_retry=True, target_tool_name="order.archive_query"), - session, - [_archive_order_tool()], - ) - - assert tool_call is not None - assert tool_call.name == "order.archive_query" - assert tool_call.arguments == {"order_id": "ARCHIVE-1001"} - - -def test_reflection_tool_retry_is_preferred_for_current_skill_target() -> None: - loop = object.__new__(AgentLoop) - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="after_sales_refund", - ) - - assert loop._reflection_tool_retry_targets_current_skill( - ReflectionDecision( - needs_retry=True, - target_skill_id="after_sales_refund", - target_tool_name="order.archive_query", - ), - session, - ) - - -def test_reflection_tool_retry_preserves_router_decision_and_streams_tool_events() -> None: - loop = object.__new__(AgentLoop) - loop.db = _FakeDb() - loop.events = _FakeEvents() - loop.tool_executor = _FakeToolExecutor() - loop._tool_activity_payload = lambda tenant_id, name, result, *args: { # type: ignore[method-assign] - "toolId": name, - "toolName": name, - "rawToolName": name, - "success": result.success, - "isError": not result.success, - "content": result.model_dump(mode="json"), - } - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="after_sales_refund", - active_step_id="check_refund_eligibility", - ) - decision = RouterDecision( - decision="continue_active", - target_skill_id="after_sales_refund", - target_step_id="check_refund_eligibility", - user_intent="申请退款", - ) - stream_events: list[tuple[str, dict[str, object]]] = [] - - active_skill, returned_decision, step_result, tool_result = ( - loop._retry_with_reflection_tool_call( - ChatTurnRequest(tenant_id="tenant_demo", message="我要退款"), - session, - None, - decision, - ToolCall(name="order.archive_query", arguments={"order_id": "ARCHIVE-1001"}), - "主工具未命中,尝试历史订单查询", - stream_events, - ) - ) - - assert active_skill is None - assert returned_decision is decision - assert step_result.tool_call is not None - assert step_result.tool_call.name == "order.archive_query" - assert tool_result is not None - assert tool_result.success - assert stream_events[0][0] == "status" - assert stream_events[0][1]["phase"] == "tool" - assert stream_events[1][0] == "tool_result" - - -def test_zero_reflection_rounds_skips_reflection_agent() -> None: - loop = object.__new__(AgentLoop) - loop.events = _FakeEvents() - loop.reflection_agent = _RaisingReflectionAgent() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - decision = RouterDecision(decision="continue_active", user_intent="申请退款") - step_result = StepAgentResult(is_step_completed=True) - tool_result = ToolResult(tool_name="order.query", success=True, data={"found": False}) - - returned = loop._run_reflection_rounds( - ChatTurnRequest(tenant_id="tenant_demo", message="我要退款"), - session, - [], - [], - ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo"), - None, - decision, - step_result, - tool_result, - 0, - ) - - assert returned == (None, decision, step_result, tool_result) - assert loop.events.records[-1][2] == "reflection_skipped" - assert loop.events.records[-1][3]["skip_reason"] == "reflection_disabled" - - -def test_clarify_greeting_does_not_trigger_reflection() -> None: - loop = object.__new__(AgentLoop) - loop.reflection_agent = _RaisingReflectionAgent() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - decision = RouterDecision(decision="clarify", user_intent="greeting") - step_result = StepAgentResult(reply="您好,请问有什么可以帮您?") - - returned = loop._run_reflection_rounds( - ChatTurnRequest(tenant_id="tenant_demo", message="你好"), - session, - [], - [], - ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo"), - None, - decision, - step_result, - None, - 1, - ) - - assert returned == (None, decision, step_result, None) - - -def test_successful_expected_tool_result_can_pass_reflection() -> None: - loop = object.__new__(AgentLoop) - loop.reflection_agent = _PassingReflectionAgent() - loop.events = _FakeEvents() - session = ChatSession(id="session_test", tenant_id="tenant_demo") - decision = RouterDecision(decision="continue_active", user_intent="查询订单") - step_result = StepAgentResult(is_step_completed=True) - tool_result = ToolResult(tool_name="order.query", success=True, data={"found": True}) - - returned = loop._run_reflection_rounds( - ChatTurnRequest(tenant_id="tenant_demo", message="查订单"), - session, - [], - [], - ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo"), - None, - decision, - step_result, - tool_result, - 1, - ) - - assert returned == (None, decision, step_result, tool_result) - - -def test_reflection_target_skill_is_scheduled_instead_of_skipped() -> None: - loop = object.__new__(AgentLoop) - loop.db = _FakeDb() - loop.events = _FakeEvents() - loop.runtime = SkillRuntime() - loop.reflection_agent = _TargetSkillReflectionAgent("price_compare") - skills = [_purchase_skill(), _price_compare_skill()] - skills_by_id = {skill.skill_id: skill for skill in skills} - captured: dict[str, object] = {} - - def run_step( - request: ChatTurnRequest, - chat_session: ChatSession, - active_skill: Skill | None, - tools: list[Tool], - model_config: ModelConfig, - router_decision: RouterDecision, - **_: object, - ) -> StepAgentResult: - captured["active_skill_id"] = active_skill.skill_id if active_skill else None - captured["router_decision"] = router_decision.model_dump(mode="json") - captured["tool_names"] = [ - tool.name - for tool in loop._step_agent_tools( - active_skill, - tools, - active_step_id=chat_session.active_step_id, - slots=chat_session.slots_json, - ) - ] - return StepAgentResult(reply="已切换到比价流程。", is_step_completed=True) - - loop._get_active_skill = lambda tenant_id, skill_id, agent_id=None: skills_by_id.get(skill_id) # type: ignore[method-assign] - loop._run_step_agent_with_context_repair = run_step # type: ignore[method-assign] - loop._skill_version = lambda tenant_id, skill_id: None # type: ignore[method-assign] - - session = ChatSession( - id="session_test", - tenant_id="tenant_demo", - active_skill_id="purchase", - active_step_id="collect_purchase", - slots_json={"product_name_1": "A1", "product_name_2": "A3"}, - ) - previous_decision = RouterDecision( - decision="continue_active", - target_skill_id="purchase", - user_intent="购买前比价", - ) - previous_step = StepAgentResult( - tool_call=ToolCall(name="product.price_query", arguments={"product_name": "A1"}), - is_step_completed=True, - ) - previous_tool_result = ToolResult( - tool_name="product.price_query", - success=False, - error={"code": "NOT_ALLOWED", "message": "当前技能不允许调用该工具。"}, - ) - - active_skill, router_decision, step_result, tool_result, retried = loop._reflect_and_retry( - ChatTurnRequest(tenant_id="tenant_demo", message="买 A1 前跟 A3 比下价格"), - session, - skills, - [_price_query_tool()], - ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo"), - _purchase_skill(), - previous_decision, - previous_step, - previous_tool_result, - conversation_context={}, - ) - - assert retried is True - assert active_skill is not None - assert active_skill.skill_id == "price_compare" - assert router_decision.decision == "start_new_task" - assert router_decision.target_skill_id == "price_compare" - assert session.active_skill_id == "price_compare" - assert session.active_step_id == "collect_products" - assert step_result.reply == "已切换到比价流程。" - assert tool_result is None - assert captured["active_skill_id"] == "price_compare" - assert captured["tool_names"] == [] - assert not any(record[2] == "reflection_retry_skipped" for record in loop.events.records) - assert any( - record[2] == "reflection_retry_started" and record[3]["mode"] == "skill" - for record in loop.events.records - ) - - -def test_router_reflection_retry_executes_knowledge_before_tool() -> None: - loop = object.__new__(AgentLoop) - loop.db = _FakeDb() - loop.events = _FakeEvents() - loop.runtime = SkillRuntime() - skill = _knowledge_skill() - loop._get_active_skill = lambda *_args, **_kwargs: skill - loop._skill_version = lambda *_args, **_kwargs: None - loop._run_step_agent_with_context_repair = lambda *_args, **_kwargs: StepAgentResult( - knowledge_query=KnowledgeQuery(query="政策") - ) - calls: list[str] = [] - - def execute_knowledge(*_args, **_kwargs): - calls.append("knowledge") - return StepAgentResult( - tool_call=ToolCall(name="policy.apply", arguments={"type": "annual"}) - ) - - def execute_tool(*args, **_kwargs): - calls.append("tool") - return args[5], ToolResult(tool_name="policy.apply", success=True, data={}) - - loop._execute_knowledge_query_cycle = execute_knowledge - loop._execute_tool_action_cycle = execute_tool - session = ChatSession(id="session_test", tenant_id="tenant_demo") - decision = RouterDecision( - decision="start_new_task", - target_skill_id="knowledge_skill", - target_step_id="check_policy", - ) - - _active_skill, _decision, _step_result, tool_result = loop._retry_with_router_decision( - ChatTurnRequest(tenant_id="tenant_demo", message="继续"), - session, - [skill], - [], - decision, - ModelConfig(tenant_id="tenant_demo", name="demo", api_key_encrypted="", model="demo"), - {}, - ) - - assert calls == ["knowledge", "tool"] - assert tool_result is not None and tool_result.success - - -class _FakeDb: - def commit(self) -> None: - pass - - def refresh(self, _row: object) -> None: - pass - - -class _FakeEvents: - def __init__(self) -> None: - self.records: list[tuple[str, str, str, dict]] = [] - - def record(self, tenant_id: str, session_id: str, event_type: str, payload: dict) -> None: - self.records.append((tenant_id, session_id, event_type, payload)) - - -class _FakeToolExecutor: - def execute( - self, - tenant_id: str, - tool_call: ToolCall, - active_skill_id: str | None, - agent_id: str | None = None, - ) -> ToolResult: - return ToolResult( - tool_name=tool_call.name, - success=True, - data={"source": "archive_order_center", "found": True}, - ) - - -class _RaisingReflectionAgent: - def review(self, *args: object, **kwargs: object) -> ReflectionDecision: - raise AssertionError("reflection agent should not be called") - - -class _PassingReflectionAgent: - def review(self, *args: object, **kwargs: object) -> ReflectionDecision: - return ReflectionDecision(action="pass", needs_retry=False) - - -class _TargetSkillReflectionAgent: - def __init__(self, target_skill_id: str) -> None: - self.target_skill_id = target_skill_id - - def review(self, *args: object, **kwargs: object) -> ReflectionDecision: - return ReflectionDecision( - action="try_other_tool", - needs_retry=True, - reason="当前技能不能调用目标工具,切到可执行技能。", - target_skill_id=self.target_skill_id, - ) - - -def _skill(skill_id: str) -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id=skill_id, - name=skill_id, - content_json={ - "skill_id": skill_id, - "name": skill_id, - "nodes": [ - { - "node_id": "start", - "type": "collect_info", - "name": "开始", - "allowed_actions": ["ask_user"], - } - ], - "edges": [], - "start_node_id": "start", - "terminal_node_ids": ["start"], - }, - status="published", - ) - - -def _knowledge_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="knowledge_skill", - name="政策流程", - content_json={ - "skill_id": "knowledge_skill", - "name": "政策流程", - "nodes": [ - { - "node_id": "check_policy", - "type": "knowledge_query", - "name": "检索政策", - "allowed_actions": ["answer_user"], - } - ], - "edges": [], - "start_node_id": "check_policy", - "terminal_node_ids": ["check_policy"], - }, - status="published", - ) - - -def _purchase_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="purchase", - name="购买商品", - content_json={ - "skill_id": "purchase", - "name": "购买商品", - "nodes": [ - { - "node_id": "collect_purchase", - "type": "collect_info", - "name": "收集购买信息", - "allowed_actions": ["ask_user", "continue_flow"], - } - ], - "edges": [], - "start_node_id": "collect_purchase", - "terminal_node_ids": ["collect_purchase"], - }, - status="published", - ) - - -def _price_compare_skill() -> Skill: - return Skill( - tenant_id="tenant_demo", - skill_id="price_compare", - name="商品比价", - content_json={ - "skill_id": "price_compare", - "name": "商品比价", - "nodes": [ - { - "node_id": "collect_products", - "type": "collect_info", - "name": "收集待比价商品", - "allowed_actions": ["ask_user", "continue_flow"], - }, - { - "node_id": "query_prices", - "type": "tool_call", - "name": "查询商品价格", - "allowed_actions": ["call_tool:product.price_query", "continue_flow"], - }, - ], - "edges": [ - { - "source_node_id": "collect_products", - "next_node_id": "query_prices", - "priority": 0, - "label": "默认推进", - } - ], - "start_node_id": "collect_products", - "terminal_node_ids": ["query_prices"], - }, - status="published", - ) - - -def _price_query_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="product.price_query", - display_name="商品价格查询", - method="POST", - url="http://localhost:8000/api/mock/product/price-query", - input_schema={ - "type": "object", - "properties": {"product_name": {"type": "string"}}, - "required": ["product_name"], - }, - allowed_skills_json=["price_compare"], - enabled=True, - ) - - -def _ticket_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="ticket.create", - display_name="创建工单", - method="POST", - url="http://localhost:8000/api/mock/ticket/create", - input_schema={ - "type": "object", - "properties": { - "customer_name": {"type": "string"}, - "asset_id": {"type": "string"}, - "issue": {"type": "string"}, - }, - "required": ["customer_name", "asset_id", "issue"], - }, - allowed_skills_json=["repair_ticket"], - enabled=True, - ) - - -def _archive_order_tool() -> Tool: - return Tool( - tenant_id="tenant_demo", - name="order.archive_query", - display_name="历史订单查询", - method="POST", - url="http://localhost:8000/api/mock/order/archive-query", - input_schema={ - "type": "object", - "properties": {"order_id": {"type": "string"}}, - "required": ["order_id"], - }, - allowed_skills_json=["after_sales_refund", "after_sales_exchange"], - enabled=True, - ) diff --git a/backend/tests/test_legacy_turn_finalizer.py b/backend/tests/test_turn_finalizer.py similarity index 92% rename from backend/tests/test_legacy_turn_finalizer.py rename to backend/tests/test_turn_finalizer.py index db7ed70f..e1550619 100644 --- a/backend/tests/test_legacy_turn_finalizer.py +++ b/backend/tests/test_turn_finalizer.py @@ -1,6 +1,6 @@ from types import SimpleNamespace -from app.core.legacy_turn_finalizer import LegacyTurnFinalizer +from app.core.turn_finalizer import TurnFinalizer from app.session.session_schema import RouterDecision, StepAgentResult @@ -9,7 +9,7 @@ def test_handoff_is_terminal_and_does_not_complete_skill() -> None: calls: list[str] = [] session = SimpleNamespace(id="session-1", active_step_id="handoff", active_skill_id="skill-1") - result = LegacyTurnFinalizer.finalize( + result = TurnFinalizer.finalize( "tenant-1", session, None, @@ -32,7 +32,7 @@ def test_ignored_handoff_preserves_completion_decision_and_event_payload() -> No events: list[tuple[str, dict[str, object]]] = [] calls: list[str] = [] session = SimpleNamespace(id="session-1", active_step_id="step-1", active_skill_id="skill-1") - result = LegacyTurnFinalizer.finalize( + result = TurnFinalizer.finalize( "tenant-1", session, None, diff --git a/contracts/agent/v1/compatibility-matrix.json b/contracts/agent/v1/compatibility-matrix.json deleted file mode 100644 index 7ee957d3..00000000 --- a/contracts/agent/v1/compatibility-matrix.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "schema_version": "1.0", - "surfaces": [ - {"surface": "ChatTurnRequest", "legacy": "authoritative", "v1": "same_public_shape"}, - {"surface": "ChatTurnResponse", "legacy": "authoritative", "v1": "additive_conversation_projection"}, - {"surface": "SSE", "legacy": "characterized", "v1": "additive_terminal_projection"}, - {"surface": "Message.metadata_json", "legacy": "read_write", "v1": "legacy_read_preserved"}, - {"surface": "Message.interaction_blocks", "legacy": "absent", "v1": "versioned_additive_projection"}, - {"surface": "ChatSession scenario state", "legacy": "execution_authority", "v1": "compatibility_projection_only"}, - {"surface": "scenario_frames", "legacy": "absent", "v1": "execution_authority"}, - {"surface": "Provider contracts", "legacy": "local_direct", "v1": "service_specific_contract_later"} - ], - "read_cases": [ - {"case": "v0_only", "read_source": "legacy", "write_behavior": "legacy_only", "metric": "compat.v0_read"}, - {"case": "v1_only", "read_source": "v1", "write_behavior": "v1_only", "metric": "compat.v1_read"}, - {"case": "both_consistent", "read_source": "v1", "write_behavior": "phase_specific", "metric": "compat.dual_consistent"}, - {"case": "both_divergent", "read_source": "phase_authority", "write_behavior": "no_read_time_repair", "error": "COMPAT_PROJECTION_DIVERGED", "metric": "compat.dual_divergent"}, - {"case": "v1_partial_or_invalid", "read_source": "legacy_if_legacy_authoritative_else_pause", "write_behavior": "no_guessing", "error": "V1_RECORD_INVALID", "metric": "compat.v1_invalid"} - ], - "frame_assignment_cases": [ - {"legacy_nonterminal_work": true, "flag": "off", "new_frame_runtime": "legacy"}, - {"legacy_nonterminal_work": true, "flag": "on", "new_frame_runtime": "legacy", "reason": "mixed_nonterminal_runtime_forbidden"}, - {"legacy_nonterminal_work": false, "flag": "off", "new_frame_runtime": "legacy"}, - {"legacy_nonterminal_work": false, "flag": "on", "new_frame_runtime": "graph_v1"}, - {"v1_nonterminal_work": true, "legacy_start_requested": true, "selected_runtime": "graph_v1", "legacy_write": "rejected", "error": "SCENARIO_RUNTIME_CONFLICT"}, - {"concurrent_claims": ["legacy", "graph_v1"], "winner": "session_cas_winner", "loser": "reread_and_route_to_winner"}, - {"resume_frame_runtime": "legacy", "flag": "any", "selected_runtime": "legacy"}, - {"resume_frame_runtime": "graph_v1", "flag": "any", "selected_runtime": "graph_v1"}, - {"resume_frame_runtime": "graph_v1", "runtime_available": false, "selected_runtime": "paused", "error": "RUNTIME_VERSION_UNAVAILABLE"} - ], - "rollback_cases": [ - {"nonterminal_v1_frames": false, "action": "disable_new_v1_assignment"}, - {"nonterminal_v1_frames": true, "runtime_available": true, "action": "disable_new_assignment_and_drain_pinned_runtime"}, - {"nonterminal_v1_frames": true, "runtime_available": false, "action": "pause_without_runtime_substitution"} - ] -} diff --git a/contracts/agent/v1/conformance-report-phase0.json b/contracts/agent/v1/conformance-report-phase0.json deleted file mode 100644 index 8a52d2ed..00000000 --- a/contracts/agent/v1/conformance-report-phase0.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "report_version": "1.0", - "contract_version": "agent/v1", - "phase_scope": "0A", - "source_revision": "6c6594d42c08ae3757a72aa0e654a3fe555e4077", - "generated_at": "2026-07-27T07:01:48Z", - "environment": {"status": "contract_and_legacy_characterization_in_progress", "database": "real_file_sqlite_ten_variants_plus_isolated_browser_sqlite", "frontend": "vitest_build_and_loopback_browser_characterization_partial"}, - "gate_decision": "no_go", - "gate_reasons": [ - "Required Legacy five-plane fixtures exist for 10 of 27 scenario variants; remaining variants are incomplete.", - "Target-v1 runtime conformance is not implemented; the automated success-terminal probe is limited to GT01 plain chat.", - "Browser characterization found a mobile layout failure; automated desktop replay, feedback rollback, Citation, Handoff, and Scheduled Draft confirmation remain uncovered." - ], - "runtime_conformance": "not_implemented", - "legacy_characterization_status": "in_progress", - "schema_validation_results": [{"id": "machine_contract_schemas", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "All 21 Draft 2020-12 schemas and 12 registered instances passed backend/tests/agent_golden; manifest orphan/duplicate checks, owned payload path resolution, recursive forbidden-key checks, and manifest-pinned Legacy field observations also passed."}], - "scenario_plane_results": [ - {"id": "captured_legacy_variants", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "GT01 sync/SSE/feedback-refresh-toggle, GT02 ask-refresh-continue, GT03 true/false branches, GT04 parallel merge, GT13 LLM error, GT15 full, and GT16 History have 50 checked-in five-plane envelopes and independent runtime recapture."}, - {"id": "remaining_GT01-GT17_variants", "status": "not_run", "evidence_mode": "not_available", "gate_required": true, "detail": "Five-plane fixture envelopes remain missing for 17 of 27 catalog variants."} - ], - "requirement_coverage": {"status": "incomplete", "covered": 0, "required": 29, "missing": ["runtime_assertions"]}, - "vocabulary_coverage": {"status": "complete", "covered": 1, "required": 1, "missing": []}, - "join_invariant_results": [ - {"id": "captured_legacy_cross_plane_joins", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "Required per-variant relationships, profile-pinned endpoint roles/path patterns/semantic qualifiers, unique non-self references, durable SSE-to-DB event_id joins, feedback target-to-History/API-response joins, attachment request-to-History joins, and public Event API graph transition/pending snapshots-to-SQLite joins pass for all ten captured variants. GT03/GT04 also retain explicit persisted pre/post Session evidence."}, - {"id": "remaining_legacy_cross_plane_joins", "status": "not_run", "evidence_mode": "not_available", "gate_required": true, "detail": "Join evidence remains absent for uncaptured variants."} - ], - "happens_before_results": [ - {"id": "GT13_error_boundary_visibility", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "A real loopback socket probe observes user-only History at error_occurred and assistant History visibility by stream_end, followed by clean close without complete."}, - {"id": "success_terminal_visibility", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "A deterministic commit-barrier probe over loopback Uvicorn proves GT01 plain-chat stream_end precedes commit, complete is absent while commit is blocked, and the non-empty durable complete SSE id is observed exactly once before a single independent History read returns the committed user and assistant messages with matching reply and turn identity. This evidence does not cover other success branches."} - ], - "pairwise_coverage": {"status": "complete", "covered": 1, "required": 1, "missing": []}, - "seam_coverage": {"status": "incomplete", "covered": 1, "required": 2, "missing": ["monkeypatch_reference_scan"]}, - "corpus_coverage": {"status": "complete", "covered": 3, "required": 3, "missing": []}, - "determinism_runs": [ - {"scenario": "GT01-sync", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT01-sync", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT01-sync", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT01-sse", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT01-sse", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT01-sse", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT01-feedback-refresh-toggle", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT01-feedback-refresh-toggle", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT01-feedback-refresh-toggle", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT02-ask-refresh-continue", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT02-ask-refresh-continue", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT02-ask-refresh-continue", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT03-true", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT03-true", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT03-true", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT03-false", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT03-false", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT03-false", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT04-merge", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT04-merge", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT04-merge", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT13-llm-error", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT13-llm-error", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT13-llm-error", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT15-full", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT15-full", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT15-full", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true}, - {"scenario": "GT16-history", "run": 1, "execution": "in_process", "hash_seed": null, "timezone": "controlled_utc_clock", "canonical_match": true}, - {"scenario": "GT16-history", "run": 2, "execution": "fresh_process", "hash_seed": "1", "timezone": "UTC", "canonical_match": true}, - {"scenario": "GT16-history", "run": 3, "execution": "fresh_process", "hash_seed": "777", "timezone": "America/New_York", "canonical_match": true} - ], - "http_sse_harness_results": [ - {"id": "real_fastapi_testclient_sqlite", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "GT01 sync/SSE, backend feedback persistence/clear and invalid-rating semantics, GT13 error, GT15 draft confirm rollback, and GT16 attachment History use real auth, routers, file SQLite, persistence, SSE relay, and History."}, - {"id": "real_socket_disconnect_cancel_and_terminal_boundaries", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "Four loopback Uvicorn/socket tests cover transport disconnect recovery, exactly-one explicit stream_cancelled with stopped History, the GT13 error/stream_end History boundary, and deterministic GT01 plain-chat complete-to-committed-History visibility."} - ], - "frontend_baseline_results": [ - {"id": "official_chat_frontend_vitest", "status": "pass", "evidence_mode": "automated", "gate_required": true, "detail": "Six Vitest checks cover Citation/Draft/Attachment History consumers, feedback eligibility, terminal vocabulary, and Scheduled Draft confirm/dismiss clicks."}, - {"id": "manual_chat_frontend_desktop_browser", "status": "pass", "evidence_mode": "manual_observation", "gate_required": false, "detail": "A manual 1280x720 loopback browser observation used the production frontend, real API/SSE/SQLite, and process-local scripted LLM boundary to cover Gallery -> draft -> session, streamed reply, History refresh, persisted feedback styling, attachment upload/refresh, and Scheduled Task mode enter/cancel; console warnings/errors were empty. This observation has no repository-owned replay script and is not an automated gate."}, - {"id": "official_chat_frontend_desktop_browser_replay", "status": "not_run", "evidence_mode": "not_available", "gate_required": true, "detail": "A repository-owned deterministic desktop browser replay is still required; the manual observation cannot satisfy this gate."}, - {"id": "official_chat_frontend_feedback_rollback", "status": "not_run", "evidence_mode": "not_available", "gate_required": true, "detail": "Optimistic feedback styling and rollback after POST/DELETE failure remain uncharacterized in the production frontend."}, - {"id": "official_chat_frontend_mobile_browser", "status": "fail", "evidence_mode": "manual_observation", "gate_required": true, "detail": "At 390x844 the persistent sidebar compresses chat to a narrow column, causes character-by-character wrapping, and introduces horizontal scrolling. This is a characterized pre-existing baseline defect; production frontend was not changed in Phase 0A."}, - {"id": "official_chat_frontend_remaining_interactions", "status": "not_run", "evidence_mode": "not_available", "gate_required": true, "detail": "Citation, Handoff, and Scheduled Draft confirm/dismiss browser flows still require seeded deterministic scenarios."} - ], - "known_legacy_defects": [ - {"id": "GT13-error-stream-no-complete", "status": "characterized", "detail": "Legacy emits error_occurred while only the user message is readable, makes assistant History visible by stream_end, later relays assistant/session events, then cleanly closes without complete."}, - {"id": "GT15-duplicate-confirm-creates-duplicate-task", "status": "characterized", "detail": "Legacy accepts a repeated scheduled draft confirmation, creates a second task, and overwrites History metadata to reference the second task. Contract v1 must be idempotent."} - ], - "missing_assets": [ - "fixture_envelopes", - "automated_desktop_browser_replay", - "frontend_feedback_rollback_characterization", - "remaining_frontend_interaction_characterization", - "responsive_frontend_baseline_fix_or_explicit_acceptance" - ], - "artifact_hashes": { - "manifest.json": "sha256:636689518d0d860cebee99fff1b3a11418b5f9e0a546f248dbf429821d8feb5f", - "scenario-catalog.json": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa", - "requirement-registry.json": "sha256:896babc35e20a5cef4884eae26b19853eccbfd7a71dcbee781db790c4b6d435e", - "scenario-vocabulary.json": "sha256:52e35c9fc1bcc0f30d0a7dac8aef1fd446e397f512de3d943b3e82b3e4c99c94", - "field-ownership.json": "sha256:3cb081a1c0de85dc69ccd69685847d027bcf0a233a084eefe2f31247536dfd98", - "compatibility-matrix.json": "sha256:a0a658df111b3bfb2f705b2b810a3b02715671414ee64844506058df087bd734", - "legacy-seams.json": "sha256:44729fa8a5fd24ebfebb3a52dfffc67a8f8264c5920977fb67540b49e79d069c", - "pairwise-manifest.json": "sha256:7aadea29485087e60d0faa0f3c974b30d5e52e3e4ecdb68e15af1ba22ae5223b", - "legacy-skill-corpus.json": "sha256:2c499cfca742779cfce6407fe0ac2bbddbbfa478bf47e09e5a3ac173ecd568e6", - "normalization-profiles.json": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66", - "relationship-requirements.json": "sha256:b3a46634a7fe472cf99462af4bd6dee53be71cb092abee11ed67ff6b57b5fc38", - "manifest_closure": "sha256:77ac568facc9580d0fff424a021aef3722d6f35b8865ebb54474f6098a059562" - }, - "reviews": [ - {"role": "architecture", "status": "revision_required", "runtime_gate": "no_go"}, - {"role": "qa", "status": "revision_required", "runtime_gate": "no_go"} - ] -} diff --git a/contracts/agent/v1/corpus/legacy_skills/leave.json b/contracts/agent/v1/corpus/legacy_skills/leave.json deleted file mode 100644 index 56a9dd65..00000000 --- a/contracts/agent/v1/corpus/legacy_skills/leave.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "skill_id": "leave", - "name": "请假申请", - "required_info": ["leave_type"], - "nodes": [ - {"node_id": "check_policy", "type": "knowledge_query", "name": "检索假期政策", "instruction": "根据请假类型检索假期政策。", "expected_user_info": ["leave_type"], "allowed_actions": ["answer_user"], "knowledge_scope": {"query_fields": ["leave_type"]}, "metadata": {}}, - {"node_id": "query_balance", "type": "tool_call", "name": "查询余额", "instruction": "", "expected_user_info": [], "allowed_actions": ["call_tool:hr.balance_query"], "knowledge_scope": {}, "metadata": {}} - ], - "edges": [ - {"source_node_id": "check_policy", "next_node_id": "query_balance", "priority": 0, "label": "默认推进"} - ], - "start_node_id": "check_policy", - "terminal_node_ids": ["query_balance"] -} diff --git a/contracts/agent/v1/corpus/legacy_skills/price_compare.json b/contracts/agent/v1/corpus/legacy_skills/price_compare.json deleted file mode 100644 index a45b06e1..00000000 --- a/contracts/agent/v1/corpus/legacy_skills/price_compare.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "skill_id": "price_compare", - "name": "商品比价", - "required_info": ["product_name_1", "product_name_2"], - "nodes": [ - {"node_id": "collect_products", "type": "collect_info", "name": "收集商品", "instruction": "", "expected_user_info": ["product_name_1", "product_name_2"], "allowed_actions": ["ask_user"], "knowledge_scope": {}, "metadata": {}}, - {"node_id": "query_price", "type": "tool_call", "name": "查询价格", "instruction": "", "expected_user_info": [], "allowed_actions": ["call_tool:product.price_query"], "knowledge_scope": {}, "metadata": {}}, - {"node_id": "reply_result", "type": "response", "name": "反馈结果", "instruction": "", "expected_user_info": [], "allowed_actions": ["answer_user"], "knowledge_scope": {}, "metadata": {}} - ], - "edges": [ - {"source_node_id": "collect_products", "next_node_id": "query_price", "priority": 0, "label": "默认推进"}, - {"source_node_id": "query_price", "next_node_id": "reply_result", "priority": 1, "label": "默认推进"} - ], - "start_node_id": "collect_products", - "terminal_node_ids": ["reply_result"] -} diff --git a/contracts/agent/v1/corpus/legacy_skills/purchase.json b/contracts/agent/v1/corpus/legacy_skills/purchase.json deleted file mode 100644 index 0c887271..00000000 --- a/contracts/agent/v1/corpus/legacy_skills/purchase.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "skill_id": "purchase", - "name": "购买商品", - "required_info": ["user_name", "product_id", "quantity"], - "nodes": [ - {"node_id": "collect_user_name", "type": "collect_info", "name": "收集用户与商品", "instruction": "", "expected_user_info": ["user_name", "product_id", "quantity"], "allowed_actions": ["ask_user"], "knowledge_scope": {}, "metadata": {}}, - {"node_id": "confirm_product", "type": "tool_call", "name": "创建订单", "instruction": "", "expected_user_info": ["product_id"], "allowed_actions": ["call_tool:product.purchase", "call_tool:order.add"], "knowledge_scope": {}, "metadata": {}}, - {"node_id": "reply_result", "type": "response", "name": "反馈订单", "instruction": "", "expected_user_info": [], "allowed_actions": ["answer_user"], "knowledge_scope": {}, "metadata": {}} - ], - "edges": [ - {"source_node_id": "collect_user_name", "next_node_id": "confirm_product", "priority": 0, "label": "默认推进"}, - {"source_node_id": "confirm_product", "next_node_id": "reply_result", "priority": 1, "label": "默认推进"} - ], - "start_node_id": "collect_user_name", - "terminal_node_ids": ["reply_result"] -} diff --git a/contracts/agent/v1/corpus/production_seed/price_compare.json b/contracts/agent/v1/corpus/production_seed/price_compare.json deleted file mode 100644 index ba391d51..00000000 --- a/contracts/agent/v1/corpus/production_seed/price_compare.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "skill_id": "skill_price_compare_001", - "name": "商品比价服务", - "version": "1.0.0", - "business_domain": "commerce", - "description": "根据用户提供的两个商品名称,查询价格、品牌和规格后给出比价结果。", - "trigger_intents": [ - "商品比价", - "价格对比", - "比下价格", - "比较价格", - "哪个更便宜" - ], - "user_utterance_examples": [ - "帮我比一下 A1 和 A3 的价格", - "买之前想看看 A1 和 iPhone 15 哪个更划算", - "A1 跟 A3 价格差多少" - ], - "goal": [ - "收集两个待比价商品", - "分别查询商品价格", - "基于工具结果给出比价结论" - ], - "required_info": [ - "product_name_1", - "product_name_2" - ], - "slot_filling_policy": { - "enabled": true, - "multi_slot_per_turn": true, - "extract_scope": "all_skill_expected_user_info", - "skip_satisfied_steps": true, - "description": "每轮同时抽取用户提到的两个待比较商品名称;如果只给出一个商品,应只追问另一个。", - "target_info": [ - "product_name_1", - "product_name_2" - ] - }, - "nodes": [ - { - "node_id": "collect_products", - "name": "收集待比价商品", - "instruction": "将本步骤作为目标而不是固定话术;从当前消息、历史对话和 slots 中识别两个待比价商品。用户一次给出两个商品时,必须同时写入 product_name_1 和 product_name_2 并继续;只缺一个商品时只追问缺失的那个,不要重复确认已给出的商品。", - "expected_user_info": [ - "product_name_1", - "product_name_2" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - }, - { - "node_id": "query_prices", - "name": "查询商品价格", - "instruction": "当 product_name_1 和 product_name_2 都已获得时,依次调用 product.price_query 查询两个商品。不要编造价格;如果只查到一个商品,应继续调用工具查询另一个商品;两个工具结果都齐全后进入结果回复。", - "expected_user_info": [], - "allowed_actions": [ - "call_tool:product.price_query", - "continue_flow" - ] - }, - { - "node_id": "reply_compare_result", - "name": "反馈比价结果", - "instruction": "基于累计工具结果对比两个商品的价格、品牌和规格,说明哪个更便宜、差价多少;如果某个商品未找到或工具失败,应明确说明无法完成该商品的比价,并给出下一步建议。", - "expected_user_info": [], - "allowed_actions": [ - "answer_user" - ] - } - ], - "interruption_policy": { - "related_question": "可以临时回答,回答后回到当前比价流程。", - "unrelated_business": "可以切换到新技能,并保存当前流程进度。", - "chitchat": "简短回应后,引导用户继续比价流程。", - "user_wants_human": "直接转人工。" - }, - "response_rules": [ - "不要在没有工具结果时编造价格。", - "若工具未查到商品,应明确说明并请用户更换商品名或转人工。", - "比价结论必须引用工具返回的价格、品牌或规格信息。", - "步骤是可自适应推进的目标,不是固定问答脚本;已由当前用户消息、历史信息或路由意图满足的内容不得重复追问,应直接推进到下一缺失信息、工具调用或最终回复。" - ] -} diff --git a/contracts/agent/v1/corpus/production_seed/purchase.json b/contracts/agent/v1/corpus/production_seed/purchase.json deleted file mode 100644 index bce32224..00000000 --- a/contracts/agent/v1/corpus/production_seed/purchase.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "skill_id": "skill_purchase_001", - "name": "购买商品流程", - "version": "1.0.0", - "business_domain": "commerce", - "description": "引导用户完成商品购买流程,包括收集用户信息、确认商品、生成订单并反馈结果。", - "trigger_intents": [ - "购买商品", - "下单", - "买东西", - "购买", - "place_order" - ], - "user_utterance_examples": [ - "我想买这个商品", - "帮我下单", - "我要购买 A1", - "我要买一个a1" - ], - "goal": [ - "获取用户身份信息", - "确认购买的商品及数量", - "确认下单意愿", - "生成有效订单", - "向用户反馈订单号及状态" - ], - "required_info": [ - "user_name", - "product_id", - "quantity" - ], - "slot_filling_policy": { - "enabled": true, - "multi_slot_per_turn": true, - "extract_scope": "all_skill_expected_user_info", - "skip_satisfied_steps": true, - "description": "每轮同时抽取用户已表达的姓名、商品 ID、购买数量和下单确认等信息;数量需理解口语数字和量词表达,已满足的信息不再追问。", - "target_info": [ - "user_name", - "product_id", - "quantity", - "purchase_confirmed" - ] - }, - "nodes": [ - { - "node_id": "collect_user_name", - "name": "收集用户信息与商品详情", - "instruction": "将本步骤作为目标而不是固定话术;同时收集用户姓名、商品 ID 和数量。用户一句话提供多个信息时必须一次性写入 slot_updates;数值字段需要理解口语数字和量词表达,例如“一个/一件/一台”表示 1,“两个/两件”表示 2,“三份/3个”表示 3。已提供的信息不再追问,只追问真正缺失的信息;全部满足后进入下单确认,不要直接创建订单。", - "expected_user_info": [ - "user_name", - "product_id", - "quantity" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - }, - { - "node_id": "confirm_purchase", - "name": "确认下单信息", - "instruction": "创建订单前必须向用户确认姓名、商品 ID 和数量。只有用户明确确认后,才能写入 purchase_confirmed=true 并继续;如果用户修改商品、数量或姓名,应更新对应 slot 并重新确认。", - "expected_user_info": [ - "purchase_confirmed" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - }, - { - "node_id": "confirm_product", - "name": "执行购买/创建订单", - "instruction": "将本步骤作为目标而不是固定话术;仅当 user_name、product_id、quantity 已满足且 purchase_confirmed=true 时,直接调用 product.purchase 或 order.add 创建订单,不要重复确认商品或数量。如果工具需要 user_id 且只有 user_name,可将 user_name 作为 user_id。", - "expected_user_info": [ - "product_id", - "quantity", - "purchase_confirmed" - ], - "allowed_actions": [ - "continue_flow", - "call_tool:product.purchase", - "call_tool:order.add" - ] - }, - { - "node_id": "create_order", - "name": "反馈订单结果", - "instruction": "将工具返回的订单号、商品信息、数量、金额和状态告知用户,确认购买结果;不要只说请稍候。", - "expected_user_info": [], - "allowed_actions": [ - "answer_user" - ] - } - ], - "interruption_policy": { - "related_question": "可以临时回答,回答后回到当前购买流程。", - "unrelated_business": "可以切换到新技能,并保存当前流程进度。", - "chitchat": "简短回应后,引导用户继续购买流程。", - "user_wants_human": "直接转人工。" - }, - "response_rules": [ - "保持语气友好、专业。", - "明确告知用户订单号。", - "创建订单前必须先向用户确认姓名、商品 ID 和数量。", - "若商品不存在或库存不足,需明确告知用户并建议其他操作。", - "步骤是可自适应推进的目标,不是固定问答脚本;已由当前用户消息、历史信息或路由意图满足的内容不得重复追问,应直接推进到下一缺失信息、工具调用或最终回复。" - ] -} diff --git a/contracts/agent/v1/corpus/production_seed/refund.json b/contracts/agent/v1/corpus/production_seed/refund.json deleted file mode 100644 index ad053063..00000000 --- a/contracts/agent/v1/corpus/production_seed/refund.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "skill_id": "after_sales_refund", - "name": "售后退款流程", - "version": "1.0.0", - "business_domain": "after_sales", - "description": "处理用户退款、退货、取消订单等诉求。", - "trigger_intents": [ - "退款", - "退货", - "取消订单", - "不想要了" - ], - "user_utterance_examples": [ - "我想退货", - "这个不要了", - "买错了能退吗", - "给我退钱" - ], - "goal": [ - "确认用户退款诉求", - "收集订单号", - "确认处理对象", - "查询订单状态", - "说明退款政策", - "引导用户继续处理或转人工" - ], - "required_info": [ - "order_id", - "refund_reason" - ], - "slot_filling_policy": { - "enabled": true, - "multi_slot_per_turn": true, - "extract_scope": "all_skill_expected_user_info", - "skip_satisfied_steps": true, - "description": "每轮同时抽取用户已表达的退款类型、订单号、退款原因和确认意愿等信息,已满足的信息不再追问。", - "target_info": [ - "refund_type", - "order_id", - "order_confirmed", - "refund_reason" - ] - }, - "nodes": [ - { - "node_id": "identify_refund_intent", - "name": "确认退款诉求", - "instruction": "将本步骤作为目标而不是固定话术;仅当用户诉求不明确时确认用户是否要退款、退货或取消订单;如果用户已明确说退货/退款/取消订单,写入 refund_type 并直接进入下一缺失信息收集,不要反问类型。", - "expected_user_info": [ - "refund_type" - ], - "allowed_actions": [ - "ask_clarification", - "continue_flow" - ] - }, - { - "node_id": "collect_order_info", - "name": "收集订单信息", - "instruction": "将本步骤作为目标而不是固定话术;如果用户未提供订单号,直接询问订单号;如果用户明确提供订单号,写入 order_id 并进入确认步骤;如果 order_id 是根据 recent_messages、上一笔订单或上下文推断出来的,必须进入确认步骤,不得直接调用工具。不要再询问用户是退货还是退款。", - "expected_user_info": [ - "order_id" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - }, - { - "node_id": "confirm_refund_order", - "name": "确认售后订单", - "instruction": "在查询或处理退款/退货/取消订单前,必须向用户确认本次要处理的订单号和诉求类型。只有用户明确确认后,才能写入 order_confirmed=true 并继续;如果用户说不是、另一个、换一个,应清空或更新 order_id 并回到订单信息收集。", - "expected_user_info": [ - "order_confirmed" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - }, - { - "node_id": "check_refund_eligibility", - "name": "查询退款资格", - "instruction": "将本步骤作为目标而不是固定话术;仅当 order_id 已存在且 order_confirmed=true 时调用 order.query;根据订单查询结果说明是否可能支持退款/退货,不要承诺一定成功;如还缺原因则继续收集,已满足时给出明确下一步。", - "expected_user_info": [], - "allowed_actions": [ - "continue_flow", - "call_tool:order.query", - "answer_user", - "handoff_human" - ] - }, - { - "node_id": "collect_refund_reason", - "name": "收集退款原因", - "instruction": "将本步骤作为目标而不是固定话术;如果用户已说明退款原因,写入 refund_reason 并继续推进;否则只追问退款原因,不重复追问退款类型或订单号。", - "expected_user_info": [ - "refund_reason" - ], - "allowed_actions": [ - "ask_user", - "continue_flow" - ] - } - ], - "interruption_policy": { - "related_question": "可以临时回答,回答后回到当前退款流程。", - "unrelated_business": "可以切换到新技能,并保存当前流程进度。", - "chitchat": "简短回应后,引导用户继续退款流程。", - "user_wants_human": "直接转人工。" - }, - "response_rules": [ - "不要承诺一定能退款。", - "未查询订单前,不要判断是否符合退款条件。", - "退款、退货或取消订单前必须先向用户确认订单号和诉求类型。", - "如果用户要求人工,应转人工。", - "步骤是可自适应推进的目标,不是固定问答脚本;已由当前用户消息、历史信息或路由意图满足的内容不得重复追问,应直接推进到下一缺失信息、工具调用或最终回复。" - ] -} diff --git a/contracts/agent/v1/field-ownership.json b/contracts/agent/v1/field-ownership.json deleted file mode 100644 index c1710ed3..00000000 --- a/contracts/agent/v1/field-ownership.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "schema_version": "1.0", - "planes": { - "provider": { - "owns": [ - {"claim": "provider_request", "payload_paths": ["/exchanges/*/request"]}, - {"claim": "provider_result", "payload_paths": ["/exchanges/*/result"]}, - {"claim": "provider_error", "payload_paths": ["/exchanges/*/error"]}, - {"claim": "provider_exchange_metadata", "payload_paths": ["/exchanges/*/service", "/exchanges/*/boundary_id", "/exchanges/*/source_symbol", "/exchanges/*/operation"]} - ], - "forbidden": ["session_state", "message_metadata", "interaction_blocks", "interaction_checks", "active_skill_id", "active_step_id", "feedback_rating"] - }, - "domain": { - "owns": [ - {"claim": "turn_request", "payload_paths": ["/request"]}, - {"claim": "router_decision", "payload_paths": ["/router_decision"]}, - {"claim": "step_result", "payload_paths": ["/step_result"]}, - {"claim": "tool_result", "payload_paths": ["/tool_result"]}, - {"claim": "turn_outcome", "payload_paths": ["/outcome"]}, - {"claim": "action_facts", "payload_paths": ["/facts"]} - ], - "forbidden": ["provider_credentials", "provider_internal_ref", "interaction_blocks", "interaction_checks"] - }, - "sse": { - "owns": [ - {"claim": "http_exchange", "payload_paths": ["/http"]}, - {"claim": "event", "payload_paths": ["/events/*/event"]}, - {"claim": "data", "payload_paths": ["/events/*/data"]}, - {"claim": "delivery_order", "payload_paths": ["/events/*/sequence"]} - ], - "forbidden": ["provider_credentials", "provider_internal_ref"] - }, - "db_events": { - "owns": [ - {"claim": "event_type", "payload_paths": ["/events/*/event_type"]}, - {"claim": "payload", "payload_paths": ["/events/*/payload"]}, - {"claim": "observed_row_order", "payload_paths": ["/events/*/observed_row_order"]} - ], - "forbidden": ["provider_credentials"] - }, - "conversation": { - "owns": [ - {"claim": "messages", "payload_paths": ["/messages"]}, - {"claim": "session", "payload_paths": ["/session"]}, - {"claim": "interaction_checks", "payload_paths": ["/interaction_checks"]}, - {"claim": "sync_response", "payload_paths": ["/sync_response"]}, - {"claim": "persisted_session_evidence", "payload_paths": ["/persisted_pre_state", "/persisted_session"]} - ], - "forbidden": ["provider_credentials", "provider_internal_ref"] - } - }, - "join_rules": [ - { - "id": "legacy.turn_message_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 2, - "allowed_references": [ - {"plane": "domain", "pointer_prefix": "/request"}, - {"plane": "domain", "pointer_prefix": "/outcome"}, - {"plane": "db_events", "pointer_prefix": "/events"}, - {"plane": "conversation", "pointer_prefix": "/messages"} - ] - }, - { - "id": "legacy.durable_event_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 2, - "allowed_references": [ - {"plane": "sse", "pointer_prefix": "/events"}, - {"plane": "db_events", "pointer_prefix": "/events"} - ] - }, - { - "id": "legacy.attachment_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 3, - "allowed_references": [ - {"plane": "domain", "pointer_prefix": "/request/attachments"}, - {"plane": "conversation", "pointer_prefix": "/messages"}, - {"plane": "conversation", "pointer_prefix": "/interaction_checks"} - ] - }, - { - "id": "legacy.feedback_target_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 3, - "allowed_references": [ - {"plane": "conversation", "pointer_prefix": "/messages"}, - {"plane": "conversation", "pointer_prefix": "/interaction_checks"} - ] - }, - { - "id": "legacy.graph_step_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 2, - "allowed_references": [ - {"plane": "domain", "pointer_prefix": "/facts"}, - {"plane": "db_events", "pointer_prefix": "/events"} - ] - }, - { - "id": "legacy.graph_pending_identity", - "fixture_sets": ["legacy_characterization"], - "operator": "equal", - "minimum_references": 2, - "allowed_references": [ - {"plane": "domain", "pointer_prefix": "/facts"}, - {"plane": "db_events", "pointer_prefix": "/events"} - ] - }, - { - "id": "target.committed_message_identity", - "fixture_sets": ["contract_v1"], - "operator": "equal", - "minimum_references": 2, - "allowed_references": [ - {"plane": "sse", "pointer_prefix": "/events"}, - {"plane": "db_events", "pointer_prefix": "/events"}, - {"plane": "conversation", "pointer_prefix": "/sync_response"}, - {"plane": "conversation", "pointer_prefix": "/messages"} - ] - } - ], - "happens_before_rules": [ - { - "id": "legacy.observed_event_order", - "fixture_sets": ["legacy_characterization"], - "order_types": ["integer", "number", "rfc3339"], - "before_references": [ - {"plane": "sse", "pointer_prefix": "/events"}, - {"plane": "db_events", "pointer_prefix": "/events"} - ], - "after_references": [ - {"plane": "sse", "pointer_prefix": "/events"}, - {"plane": "db_events", "pointer_prefix": "/events"} - ] - } - ], - "legacy_field_observation": { - "expectations": { - "run_id": {"default": "absent", "overrides": {}}, - "interaction_id": {"default": "absent", "overrides": {}} - } - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/conversation.json deleted file mode 100644 index a069a3db..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/conversation.json +++ /dev/null @@ -1,240 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-feedback-refresh-toggle", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:645766132b3c8805304a396ac032dff1de4449ce574639a97e73843a3944cf61", - "joins": [ - { - "name": "feedback-target-message-identity", - "rule_id": "legacy.feedback_target_identity", - "references": [ - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - }, - { - "role": "action_feedback_target_id", - "plane": "conversation", - "pointer": "/interaction_checks/0/action_result/resource_id" - }, - { - "role": "feedback_response_message_id", - "plane": "conversation", - "pointer": "/interaction_checks/0/action_result/persisted_state/up_response/message_id" - } - ] - } - ], - "happens_before": [], - "payload": { - "sync_response": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; try general skills, then answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请回复后接受评价", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - } - }, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "请回复后接受评价。", - "metadata": { - "client_turn_id": "client-feedback" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.002Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请回复后接受评价", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.004Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.004Z" - }, - "interaction_checks": [ - { - "kind": "feedback", - "realtime_observation": "not_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "feedback-up-invalid-clear", - "evidence_origin": "harness_synthetic", - "resource_id": "", - "action": "rate_up_reject_invalid_clear", - "request": { - "set_rating": "up", - "invalid_rating": "invalid", - "clear": true - }, - "response_status": 200, - "persisted_state": { - "initial_rating": null, - "up_status": 200, - "up_response": { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "message_id": "", - "rating": "up", - "analysis_status": "pending", - "updated_at": "2000-01-01T00:00:00.008Z" - }, - "rating_after_up_refresh": "up", - "invalid_status": 422, - "invalid_response": { - "detail": [ - { - "type": "literal_error", - "loc": [ - "body", - "rating" - ], - "msg": "Input should be 'up' or 'down'", - "input": "invalid", - "ctx": { - "expected": "'up' or 'down'" - } - } - ] - }, - "rating_after_invalid_refresh": "up", - "clear_response": { - "status": "deleted" - }, - "rating_after_clear_refresh": null - } - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/db_events.json deleted file mode 100644 index c4254f96..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/db_events.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-feedback-refresh-toggle", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:a2212ad67cdb29c62771e8a86ca3a72aa379f4285260fc967fdca3a2c19c1dfc", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/0/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/1/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "message_id": "", - "client_turn_id": "client-feedback", - "message": "请回复后接受评价。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-feedback" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请回复后接受评价", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-feedback" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "message_feedback_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "message_id": "", - "rating": "up", - "user_id": "user_golden" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "message_feedback_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "message_id": "", - "rating": null, - "user_id": "user_golden" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/domain.json deleted file mode 100644 index c0dab549..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/domain.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-feedback-refresh-toggle", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:bd332fd982a1a5d2705dcd3fc1719e6f50e16dcecfcb648a2044379eb35d9168", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/0/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/1/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "请回复后接受评价。", - "client_turn_id": "client-feedback", - "turn_id": "" - }, - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; try general skills, then answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [ - { - "kind": "feedback_history_lifecycle", - "assistant_message_id": "", - "db_event_types": [ - "message_feedback_changed", - "message_feedback_changed" - ] - } - ], - "termination": "sync_response" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/provider.json deleted file mode 100644 index a74bd7a8..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-feedback-refresh-toggle", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT01-feedback-refresh-toggle." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/sse.json deleted file mode 100644 index 14eff2f7..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-feedback-refresh-toggle/sse.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-feedback-refresh-toggle", - "plane": "sse", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "sse is not_applicable for GT01-feedback-refresh-toggle." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/conversation.json deleted file mode 100644 index 3360c070..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/conversation.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:a9d11fc0d224eb9980d680e21a31dda5652c715f18fc6e2cc2faa216f8393cc4", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": null, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "你好,请流式回复。", - "metadata": { - "client_turn_id": "client-gt01-sse" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请流式回复", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.011Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.011Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/db_events.json deleted file mode 100644 index ea46e54d..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/db_events.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:820bd24efce1cd51ffae8e794ad39e248448a96b89decf779bb12cc9e5d0c146", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/1/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/7/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "session_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt01-sse", - "message": "你好,请流式回复。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "content": "这是 Golden ", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "content": "测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请流式回复", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "complete", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请流式回复", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/domain.json deleted file mode 100644 index b2a52023..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/domain.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:098b421088548e63b09391f50a3270de86a67163d055dd5595e1f4875c27d3a7", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/1/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/7/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "你好,请流式回复。", - "client_turn_id": "client-gt01-sse", - "turn_id": "" - }, - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [], - "termination": "complete" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/provider.json deleted file mode 100644 index 23cd9dc6..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT01-sse." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/sse.json deleted file mode 100644 index ad754a41..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sse/sse.json +++ /dev/null @@ -1,451 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sse", - "plane": "sse", - "applicability": "required", - "payload_schema": "planes/sse.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:8288bb6f4e3ed31a6512d45d3dd0ab5ac925bcf0b104949d185adb1ecfae0120", - "joins": [ - { - "name": "durable-session-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/0/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/0/event_id" - } - ] - }, - { - "name": "durable-user-message-received-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/1/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/1/event_id" - } - ] - }, - { - "name": "durable-status-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/2/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/2/event_id" - } - ] - }, - { - "name": "durable-status-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/3/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/3/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/4/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/4/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/5/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/5/event_id" - } - ] - }, - { - "name": "durable-stream-end-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/6/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/6/event_id" - } - ] - }, - { - "name": "durable-assistant-message-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/7/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/7/event_id" - } - ] - }, - { - "name": "durable-session-state-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/8/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/8/event_id" - } - ] - }, - { - "name": "durable-complete-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/9/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/9/event_id" - } - ] - } - ], - "happens_before": [ - { - "name": "user-event-before-complete", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_user_event_sequence", - "plane": "sse", - "pointer": "/events/1/sequence" - }, - "after": { - "role": "sse_complete_sequence", - "plane": "sse", - "pointer": "/events/9/sequence" - } - } - ], - "payload": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": 200, - "content_type": "text/event-stream; charset=utf-8" - }, - "events": [ - { - "sequence": 0, - "id": "", - "event": "session_created", - "data": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "sequence": 1, - "id": "", - "event": "user_message_received", - "data": { - "kind": "user_message_received", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.005Z", - "provider": "skill", - "message_id": "", - "client_turn_id": "client-gt01-sse", - "message": "你好,请流式回复。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "sequence": 2, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.006Z", - "provider": "skill", - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 3, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.007Z", - "provider": "skill", - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 4, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.008Z", - "provider": "skill", - "content": "这是 Golden ", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 5, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.009Z", - "provider": "skill", - "content": "测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 6, - "id": "", - "event": "stream_end", - "data": { - "kind": "stream_end", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.010Z", - "provider": "skill", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 7, - "id": "", - "event": "assistant_message_created", - "data": { - "kind": "assistant_message_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.013Z", - "provider": "skill", - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 8, - "id": "", - "event": "session_state_changed", - "data": { - "kind": "session_state_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.014Z", - "provider": "skill", - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请流式回复", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt01-sse" - } - }, - { - "sequence": 9, - "id": "", - "event": "complete", - "data": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请流式回复", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/conversation.json deleted file mode 100644 index 12f9101a..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/conversation.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sync", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:567885f8ac0e5b56dd61ed3d641ee34aaaaecba7304157ffa230ff63ecdf2c5d", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; try general skills, then answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请介绍一下自己", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - } - }, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "你好,请介绍一下自己。", - "metadata": { - "client_turn_id": "client-gt01-sync" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.002Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请介绍一下自己", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.004Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.004Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/db_events.json deleted file mode 100644 index 6bcf1d3b..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/db_events.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sync", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:adf2074a09dd4748550b34c0f9e7cda9cccb1c630e68674cbe8acbf20af44279", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/0/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/1/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt01-sync", - "message": "你好,请介绍一下自己。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt01-sync" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "你好,请介绍一下自己", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt01-sync" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/domain.json deleted file mode 100644 index 2407bbde..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/domain.json +++ /dev/null @@ -1,141 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sync", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:721638ffcaac9e51daa6fa0573025ac0f389595a522abcd6528f47f7a773a43e", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/0/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/1/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "你好,请介绍一下自己。", - "client_turn_id": "client-gt01-sync", - "turn_id": "" - }, - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; try general skills, then answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [], - "termination": "sync_response" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/provider.json deleted file mode 100644 index 9ddd39e1..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sync", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT01-sync." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/sse.json deleted file mode 100644 index 43ba620f..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT01/GT01-sync/sse.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT01", - "variant_id": "GT01-sync", - "plane": "sse", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "sse is not_applicable for GT01-sync." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/conversation.json deleted file mode 100644 index 4e994e8f..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/conversation.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT02", - "variant_id": "GT02-ask-refresh-continue", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:cb8c2d922e856c00e06928950a93509e4a8a1b6ef78e6760d74d1c41b21628e3", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": null, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "我要购买 A1。", - "metadata": { - "client_turn_id": "client-gt02-ask" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "请告诉我您的姓名和购买数量。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.021Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "我是小明,要买两件。", - "metadata": { - "client_turn_id": "client-gt02-continue" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.028Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "请确认:小明购买 A1 两件,是否下单?", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.047Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "status": "active", - "summary": "最近回复:请确认:小明购买 A1 两件,是否下单?", - "last_agent_question": "请确认:小明购买 A1 两件,是否下单?", - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.046Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "confirm_purchase", - "expected_fields": [ - "purchase_confirmed" - ], - "question_summary": "请确认:小明购买 A1 两件,是否下单?" - }, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.046Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/db_events.json deleted file mode 100644 index 5ad6213b..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/db_events.json +++ /dev/null @@ -1,1084 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT02", - "variant_id": "GT02-ask-refresh-continue", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:1d96145e5231153e2559e248e46520d28f688f269230e5a76bdaf3ec0d61c62a", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/21/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/38/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "session_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt02-ask", - "message": "我要购买 A1。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "router_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "decision": "start_new_task", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "购买 A1", - "general_intent": null, - "reason": "Start purchase flow.", - "source_message": null, - "clarification_question": null, - "slot_hints": { - "product_id": "A1" - }, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "start_new_task", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "购买 A1", - "reason": "Start purchase flow.", - "source_message": null, - "slot_hints": { - "product_id": "A1" - } - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "skill_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "decision": "start_new_task", - "from_skill_id": null, - "to_skill_id": "skill_purchase_001", - "from_skill_version": null, - "to_skill_version": "1.0.0", - "from_step_id": null, - "to_step_id": "collect_user_name", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "skill_state", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "activeSkillId": "skill_purchase_001", - "activeStepId": "collect_user_name", - "currentSkills": [ - { - "skillId": "skill_purchase_001", - "name": "购买商品流程", - "stepId": "collect_user_name", - "state": "active" - } - ], - "runtimeDecision": "start_new_task", - "fromSkillId": null, - "fromStepId": null, - "toSkillId": "skill_purchase_001", - "toStepId": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "phase": "stepping", - "text": "正在思考", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "action": "ask_user", - "reply": "请告诉我您的姓名和购买数量。", - "slot_updates": { - "product_id": "A1" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "slot_updates": { - "product_id": "A1" - }, - "slots": { - "product_id": "A1" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "action": "ask_user", - "reply": "请告诉我您的姓名和购买数量。", - "slot_updates": { - "product_id": "A1" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false, - "repair_reason": "slot_validation", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "slot_updates": { - "product_id": "A1" - }, - "slots": { - "product_id": "A1" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "step_agent_result_repaired", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "payload": { - "mode": "slot_validation", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "missing_expected_user_info": [ - "user_name", - "quantity" - ], - "slot_updates": { - "product_id": "A1" - }, - "tool_call": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 12, - "event_id": "", - "event_type": "step_result", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "action": "ask_user", - "reply": "请告诉我您的姓名和购买数量。", - "slot_updates": { - "product_id": "A1" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 13, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 14, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.018Z", - "payload": { - "content": "请告诉我您的姓名", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 15, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.019Z", - "payload": { - "content": "和购买数量。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 16, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.020Z", - "payload": { - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 17, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.022Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "请告诉我您的姓名和购买数量。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 18, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.023Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "slots": { - "product_id": "A1" - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "collect_user_name", - "expected_fields": [ - "user_name", - "quantity" - ], - "question_summary": "请告诉我您的姓名和购买数量。" - }, - "knowledge_context": [], - "summary": "最近回复:请告诉我您的姓名和购买数量。", - "last_agent_question": "请告诉我您的姓名和购买数量。", - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-ask" - } - }, - { - "observed_row_order": 19, - "event_id": "", - "event_type": "complete", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.025Z", - "payload": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.024Z", - "provider": "skill", - "reply": "请告诉我您的姓名和购买数量。", - "session_id": "", - "router_decision": { - "decision": "start_new_task", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "购买 A1", - "general_intent": null, - "reason": "Start purchase flow.", - "source_message": null, - "clarification_question": null, - "slot_hints": { - "product_id": "A1" - }, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "start_new_task", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "购买 A1", - "reason": "Start purchase flow.", - "source_message": null, - "slot_hints": { - "product_id": "A1" - } - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "ask_user", - "reply": "请告诉我您的姓名和购买数量。", - "slot_updates": { - "product_id": "A1" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "slots": { - "product_id": "A1" - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "collect_user_name", - "expected_fields": [ - "user_name", - "quantity" - ], - "question_summary": "请告诉我您的姓名和购买数量。" - }, - "knowledge_context": [], - "summary": "最近回复:请告诉我您的姓名和购买数量。", - "last_agent_question": "请告诉我您的姓名和购买数量。", - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - }, - { - "observed_row_order": 20, - "event_id": "", - "event_type": "session_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.027Z", - "payload": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.026Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "observed_row_order": 21, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.029Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt02-continue", - "message": "我是小明,要买两件。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 22, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.030Z", - "payload": { - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 23, - "event_id": "", - "event_type": "router_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.031Z", - "payload": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "general_intent": null, - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 24, - "event_id": "", - "event_type": "skill_state", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.032Z", - "payload": { - "activeSkillId": "skill_purchase_001", - "activeStepId": "collect_user_name", - "currentSkills": [ - { - "skillId": "skill_purchase_001", - "name": "购买商品流程", - "stepId": "collect_user_name", - "state": "active" - } - ], - "runtimeDecision": "continue_active", - "fromSkillId": "skill_purchase_001", - "fromStepId": "collect_user_name", - "toSkillId": "skill_purchase_001", - "toStepId": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 25, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.033Z", - "payload": { - "phase": "stepping", - "text": "正在思考", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 26, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.034Z", - "payload": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 27, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.035Z", - "payload": { - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 28, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.036Z", - "payload": { - "from_skill_id": "skill_purchase_001", - "to_skill_id": "skill_purchase_001", - "from_step_id": "collect_user_name", - "to_step_id": "confirm_purchase", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 29, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.037Z", - "payload": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "repair_reason": "slot_validation", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 30, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.038Z", - "payload": { - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 31, - "event_id": "", - "event_type": "step_agent_result_repaired", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.039Z", - "payload": { - "mode": "slot_validation", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "missing_expected_user_info": [ - "purchase_confirmed" - ], - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 32, - "event_id": "", - "event_type": "step_result", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.040Z", - "payload": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 33, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.041Z", - "payload": { - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 34, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.042Z", - "payload": { - "content": "请确认:小明购买", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 35, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.043Z", - "payload": { - "content": " A1 两件,是", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 36, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.044Z", - "payload": { - "content": "否下单?", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 37, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.045Z", - "payload": { - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 38, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.048Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 39, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.049Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "confirm_purchase", - "expected_fields": [ - "purchase_confirmed" - ], - "question_summary": "请确认:小明购买 A1 两件,是否下单?" - }, - "knowledge_context": [], - "summary": "最近回复:请确认:小明购买 A1 两件,是否下单?", - "last_agent_question": "请确认:小明购买 A1 两件,是否下单?", - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "observed_row_order": 40, - "event_id": "", - "event_type": "complete", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.051Z", - "payload": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.050Z", - "provider": "skill", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "session_id": "", - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "general_intent": null, - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "confirm_purchase", - "expected_fields": [ - "purchase_confirmed" - ], - "question_summary": "请确认:小明购买 A1 两件,是否下单?" - }, - "knowledge_context": [], - "summary": "最近回复:请确认:小明购买 A1 两件,是否下单?", - "last_agent_question": "请确认:小明购买 A1 两件,是否下单?", - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/domain.json deleted file mode 100644 index d07c99b6..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/domain.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT02", - "variant_id": "GT02-ask-refresh-continue", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:28df73273c91fac988f504101569ef1835a19b119e2e2bd7399330e4936e5a05", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/21/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/2/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/38/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/3/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "我是小明,要买两件。", - "client_turn_id": "client-gt02-continue", - "turn_id": "", - "session_id": "" - }, - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "general_intent": null, - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "请确认:小明购买 A1 两件,是否下单?", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [ - { - "kind": "first_turn_refresh_state", - "first_turn_terminal": "complete", - "assistant_reply": "请告诉我您的姓名和购买数量。", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "awaiting_input": null - } - ], - "termination": "complete" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/provider.json deleted file mode 100644 index aa357bb6..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT02", - "variant_id": "GT02-ask-refresh-continue", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT02-ask-refresh-continue." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/sse.json deleted file mode 100644 index d1c36736..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT02/GT02-ask-refresh-continue/sse.json +++ /dev/null @@ -1,945 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT02", - "variant_id": "GT02-ask-refresh-continue", - "plane": "sse", - "applicability": "required", - "payload_schema": "planes/sse.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:ffc71c6f64de6a6e7df11dba83aff9f3c533d55b9a5264a579cedee40a7f613b", - "joins": [ - { - "name": "durable-session-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/0/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/20/event_id" - } - ] - }, - { - "name": "durable-user-message-received-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/1/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/21/event_id" - } - ] - }, - { - "name": "durable-status-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/2/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/22/event_id" - } - ] - }, - { - "name": "durable-router-decision-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/3/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/23/event_id" - } - ] - }, - { - "name": "durable-skill-state-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/4/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/24/event_id" - } - ] - }, - { - "name": "durable-status-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/5/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/25/event_id" - } - ] - }, - { - "name": "durable-step-agent-result-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/6/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/26/event_id" - } - ] - }, - { - "name": "durable-slot-updated-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/7/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/27/event_id" - } - ] - }, - { - "name": "durable-skill-step-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/8/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/28/event_id" - } - ] - }, - { - "name": "durable-step-agent-result-created-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/9/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/29/event_id" - } - ] - }, - { - "name": "durable-slot-updated-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/10/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/30/event_id" - } - ] - }, - { - "name": "durable-step-agent-result-repaired-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/11/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/31/event_id" - } - ] - }, - { - "name": "durable-step-result-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/12/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/32/event_id" - } - ] - }, - { - "name": "durable-status-event-3-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/13/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/33/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/14/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/34/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/15/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/35/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-3-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/16/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/36/event_id" - } - ] - }, - { - "name": "durable-stream-end-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/17/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/37/event_id" - } - ] - }, - { - "name": "durable-assistant-message-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/18/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/38/event_id" - } - ] - }, - { - "name": "durable-session-state-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/19/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/39/event_id" - } - ] - }, - { - "name": "durable-complete-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/20/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/40/event_id" - } - ] - } - ], - "happens_before": [ - { - "name": "user-event-before-complete", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_user_event_sequence", - "plane": "sse", - "pointer": "/events/1/sequence" - }, - "after": { - "role": "sse_complete_sequence", - "plane": "sse", - "pointer": "/events/20/sequence" - } - } - ], - "payload": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": 200, - "content_type": "text/event-stream; charset=utf-8" - }, - "events": [ - { - "sequence": 0, - "id": "", - "event": "session_created", - "data": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.026Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "sequence": 1, - "id": "", - "event": "user_message_received", - "data": { - "kind": "user_message_received", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.029Z", - "provider": "skill", - "message_id": "", - "client_turn_id": "client-gt02-continue", - "message": "我是小明,要买两件。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "sequence": 2, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.030Z", - "provider": "skill", - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 3, - "id": "", - "event": "router_decision", - "data": { - "kind": "router_decision", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.031Z", - "provider": "skill", - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "general_intent": null, - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 4, - "id": "", - "event": "skill_state", - "data": { - "kind": "skill_state", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.032Z", - "provider": "skill", - "activeSkillId": "skill_purchase_001", - "activeStepId": "collect_user_name", - "currentSkills": [ - { - "skillId": "skill_purchase_001", - "name": "购买商品流程", - "stepId": "collect_user_name", - "state": "active" - } - ], - "runtimeDecision": "continue_active", - "fromSkillId": "skill_purchase_001", - "fromStepId": "collect_user_name", - "toSkillId": "skill_purchase_001", - "toStepId": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 5, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.033Z", - "provider": "skill", - "phase": "stepping", - "text": "正在思考", - "active_skill_id": "skill_purchase_001", - "active_step_id": "collect_user_name", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 6, - "id": "", - "event": "step_agent_result_created", - "data": { - "kind": "step_agent_result_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.034Z", - "provider": "skill", - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 7, - "id": "", - "event": "slot_updated", - "data": { - "kind": "slot_updated", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.035Z", - "provider": "skill", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 8, - "id": "", - "event": "skill_step_changed", - "data": { - "kind": "skill_step_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.036Z", - "provider": "skill", - "from_skill_id": "skill_purchase_001", - "to_skill_id": "skill_purchase_001", - "from_step_id": "collect_user_name", - "to_step_id": "confirm_purchase", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 9, - "id": "", - "event": "step_agent_result_created", - "data": { - "kind": "step_agent_result_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.037Z", - "provider": "skill", - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "repair_reason": "slot_validation", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 10, - "id": "", - "event": "slot_updated", - "data": { - "kind": "slot_updated", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.038Z", - "provider": "skill", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 11, - "id": "", - "event": "step_agent_result_repaired", - "data": { - "kind": "step_agent_result_repaired", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.039Z", - "provider": "skill", - "mode": "slot_validation", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "missing_expected_user_info": [ - "purchase_confirmed" - ], - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 12, - "id": "", - "event": "step_result", - "data": { - "kind": "step_result", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.040Z", - "provider": "skill", - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 13, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.041Z", - "provider": "skill", - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 14, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.042Z", - "provider": "skill", - "content": "请确认:小明购买", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 15, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.043Z", - "provider": "skill", - "content": " A1 两件,是", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 16, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.044Z", - "provider": "skill", - "content": "否下单?", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 17, - "id": "", - "event": "stream_end", - "data": { - "kind": "stream_end", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.045Z", - "provider": "skill", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 18, - "id": "", - "event": "assistant_message_created", - "data": { - "kind": "assistant_message_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.048Z", - "provider": "skill", - "message_id": "", - "assistant_message_id": "", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 19, - "id": "", - "event": "session_state_changed", - "data": { - "kind": "session_state_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.049Z", - "provider": "skill", - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "confirm_purchase", - "expected_fields": [ - "purchase_confirmed" - ], - "question_summary": "请确认:小明购买 A1 两件,是否下单?" - }, - "knowledge_context": [], - "summary": "最近回复:请确认:小明购买 A1 两件,是否下单?", - "last_agent_question": "请确认:小明购买 A1 两件,是否下单?", - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt02-continue" - } - }, - { - "sequence": 20, - "id": "", - "event": "complete", - "data": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.050Z", - "provider": "skill", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "session_id": "", - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "general_intent": null, - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_purchase_001", - "target_step_id": "collect_user_name", - "confidence": 1.0, - "user_intent": "补充购买信息", - "reason": "Continue purchase flow after refresh.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "ask_user", - "reply": "请确认:小明购买 A1 两件,是否下单?", - "slot_updates": { - "user_name": "小明", - "product_id": "A1", - "quantity": 2 - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "confirm_purchase", - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "我要购买 A1", - "active_skill_id": "skill_purchase_001", - "active_step_id": "confirm_purchase", - "slots": { - "product_id": "A1", - "user_name": "小明", - "quantity": 2 - }, - "pending_tasks": [], - "awaiting_input": { - "skill_id": "skill_purchase_001", - "step_id": "confirm_purchase", - "expected_fields": [ - "purchase_confirmed" - ], - "question_summary": "请确认:小明购买 A1 两件,是否下单?" - }, - "knowledge_context": [], - "summary": "最近回复:请确认:小明购买 A1 两件,是否下单?", - "last_agent_question": "请确认:小明购买 A1 两件,是否下单?", - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/conversation.json deleted file mode 100644 index 9dc2f841..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/conversation.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-false", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:7f35172ddea72b7e3ff22206114a551f901d8cdf9118b2657f1e0b7a95cc66d9", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "reject", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "审核拒绝。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核拒绝", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - } - }, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "审核拒绝。", - "metadata": { - "client_turn_id": "client-gt03-false" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核拒绝", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "persisted_pre_state": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": "skill_conditional_audit", - "active_step_id": "start", - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.002Z" - }, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/db_events.json deleted file mode 100644 index c2e1a956..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/db_events.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-false", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:b14a6eb3ad58865c057d4bf6066ea9a89f6ee54e89c17d7e58284e98437b0c7e", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/0/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/10/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt03-false", - "message": "审核拒绝。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "router_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "Exercise the selected exclusive branch.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_conditional_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "审核报文", - "reason": "Exercise the selected exclusive branch.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "action": "advance", - "reply": "审核分支已选择。", - "slot_updates": { - "message_content": "Golden 审核报文" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "reject", - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "slot_updates": { - "message_content": "Golden 审核报文" - }, - "slots": { - "message_content": "Golden 审核报文" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "from_skill_id": "skill_conditional_audit", - "to_skill_id": "skill_conditional_audit", - "from_step_id": "start", - "to_step_id": "reject", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "graph_auto_progress_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": "skill_conditional_audit", - "active_step_id": "reject", - "pending_step_ids": [], - "iteration": 1, - "max_iterations": 6, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "reflection_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "action": "pass", - "needs_retry": false, - "reason": "Scripted pass.", - "target_skill_id": null, - "target_step_id": null, - "target_tool_name": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "skill_completed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "skill_id": "skill_conditional_audit", - "step_id": "reject", - "reason": "step_completed", - "resumed_skill_id": null, - "resumed_step_id": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt03-false" - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核拒绝", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/domain.json deleted file mode 100644 index fa3d4c53..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/domain.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-false", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:5afaf1e21f0530a1b82f48a54a2439c75a2e739675e2983901e9ca1e86d717b7", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/0/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/10/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - }, - { - "name": "selected-graph-step-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_selected_step_id", - "plane": "domain", - "pointer": "/facts/0/selected_step_id" - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": "/events/4/payload/to_step_id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "审核拒绝。", - "client_turn_id": "client-gt03-false", - "turn_id": "", - "session_id": "" - }, - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "reject", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "审核拒绝。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [ - { - "kind": "exclusive_graph_branch", - "observation_source": "session_events_api", - "selected_step_id": "reject", - "step_transitions": [ - { - "from_skill_id": "skill_conditional_audit", - "to_skill_id": "skill_conditional_audit", - "from_step_id": "start", - "to_step_id": "reject", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - ], - "pending_step_updates": [ - { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-false" - } - ], - "llm_phase_order": [ - "json:Router", - "json:Step Agent", - "json:Step Agent", - "json:Reflection", - "text:Response Generator" - ] - } - ], - "termination": "sync_response" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/provider.json deleted file mode 100644 index 094f7884..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-false", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT03-false." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/sse.json deleted file mode 100644 index 9fbde58e..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-false/sse.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-false", - "plane": "sse", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "sse is not_applicable for GT03-false." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/conversation.json deleted file mode 100644 index 656f32e6..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/conversation.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-true", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:5cb286cb7a7b04b91a90c4a3574a40e0be497ad34f8947efb6d2d4b33193f0f0", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "approve", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "审核通过。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核通过", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - } - }, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "审核通过。", - "metadata": { - "client_turn_id": "client-gt03-true" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核通过", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "persisted_pre_state": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": "skill_conditional_audit", - "active_step_id": "start", - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.002Z" - }, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/db_events.json deleted file mode 100644 index 459ec7c1..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/db_events.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-true", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:dc80780e457ddb93da659ee6a02be7acf69986325b848fee47aec58793b955d4", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/0/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/10/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt03-true", - "message": "审核通过。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "router_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "Exercise the selected exclusive branch.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_conditional_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "审核报文", - "reason": "Exercise the selected exclusive branch.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "action": "advance", - "reply": "审核分支已选择。", - "slot_updates": { - "message_content": "Golden 审核报文" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "approve", - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "slot_updates": { - "message_content": "Golden 审核报文" - }, - "slots": { - "message_content": "Golden 审核报文" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "from_skill_id": "skill_conditional_audit", - "to_skill_id": "skill_conditional_audit", - "from_step_id": "start", - "to_step_id": "approve", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "graph_auto_progress_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": "skill_conditional_audit", - "active_step_id": "approve", - "pending_step_ids": [], - "iteration": 1, - "max_iterations": 6, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "reflection_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "action": "pass", - "needs_retry": false, - "reason": "Scripted pass.", - "target_skill_id": null, - "target_step_id": null, - "target_tool_name": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "skill_completed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "skill_id": "skill_conditional_audit", - "step_id": "approve", - "reason": "step_completed", - "resumed_skill_id": null, - "resumed_step_id": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt03-true" - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "审核通过", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/domain.json deleted file mode 100644 index 6fd3c8b1..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/domain.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-true", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:37c83dd6910c4a07134f0033f2ba72bd6cc8af9191c41b0b5470d3994da404ae", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/0/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/10/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - }, - { - "name": "selected-graph-step-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_selected_step_id", - "plane": "domain", - "pointer": "/facts/0/selected_step_id" - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": "/events/4/payload/to_step_id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "审核通过。", - "client_turn_id": "client-gt03-true", - "turn_id": "", - "session_id": "" - }, - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_conditional_audit", - "target_step_id": "approve", - "confidence": 1.0, - "user_intent": "审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "审核通过。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "审核结果已确认。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [ - { - "kind": "exclusive_graph_branch", - "observation_source": "session_events_api", - "selected_step_id": "approve", - "step_transitions": [ - { - "from_skill_id": "skill_conditional_audit", - "to_skill_id": "skill_conditional_audit", - "from_step_id": "start", - "to_step_id": "approve", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - ], - "pending_step_updates": [ - { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt03-true" - } - ], - "llm_phase_order": [ - "json:Router", - "json:Step Agent", - "json:Step Agent", - "json:Reflection", - "text:Response Generator" - ] - } - ], - "termination": "sync_response" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/provider.json deleted file mode 100644 index 4fc5e6db..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-true", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT03-true." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/sse.json deleted file mode 100644 index e9cd0466..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT03/GT03-true/sse.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT03", - "variant_id": "GT03-true", - "plane": "sse", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "sse is not_applicable for GT03-true." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/conversation.json deleted file mode 100644 index 9695ed67..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/conversation.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT04", - "variant_id": "GT04-merge", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:4219b6c256bfbb4a7b890a78f6fd0cd52fb508171e5a22bb6d2fd72bdf6f8eb4", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_parallel_audit", - "target_step_id": "report", - "confidence": 1.0, - "user_intent": "并行审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "并行检查这条报文。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "并行审核报告已生成。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "并行检查这条报文", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - } - }, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "并行检查这条报文。", - "metadata": { - "client_turn_id": "client-gt04-merge" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.025Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "并行检查这条报文", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.024Z" - }, - "persisted_pre_state": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": "skill_parallel_audit", - "active_step_id": "start", - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.002Z" - }, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.024Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/db_events.json deleted file mode 100644 index 62f7306b..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/db_events.json +++ /dev/null @@ -1,514 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT04", - "variant_id": "GT04-merge", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:b4f41a67df1388e8d4b1a0888a62d3c1719a9e63da9b141d10047799979378ed", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/0/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/20/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "payload": { - "message_id": "", - "client_turn_id": "client-gt04-merge", - "message": "并行检查这条报文。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "router_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_parallel_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "并行审核报文", - "general_intent": null, - "reason": "Exercise sibling ordering and merge.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [ - { - "task_id": null, - "status": "pending", - "decision": "continue_active", - "target_skill_id": "skill_parallel_audit", - "target_step_id": "start", - "confidence": 1.0, - "user_intent": "并行审核报文", - "reason": "Exercise sibling ordering and merge.", - "source_message": null, - "slot_hints": {} - } - ], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null, - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "action": "advance", - "reply": "开始收款方检查。", - "slot_updates": { - "message_content": "Golden 并行审核报文" - }, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "check_payee", - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "slot_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "slot_updates": { - "message_content": "Golden 并行审核报文" - }, - "slots": { - "message_content": "Golden 并行审核报文" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "pending_step_ids": [ - "check_sensitive" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "start", - "to_step_id": "check_payee", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "graph_auto_progress_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": "skill_parallel_audit", - "active_step_id": "check_payee", - "pending_step_ids": [ - "check_sensitive" - ], - "iteration": 1, - "max_iterations": 6, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "action": "advance", - "reply": "收款方检查完成。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "report", - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "pending_step_ids": [ - "check_sensitive", - "report" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "pending_step_ids": [ - "report" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "check_payee", - "to_step_id": "check_sensitive", - "reason": "graph_sibling_step", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "graph_auto_progress_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "payload": { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": "skill_parallel_audit", - "active_step_id": "check_sensitive", - "pending_step_ids": [ - "report" - ], - "iteration": 2, - "max_iterations": 6, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 12, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "action": "advance", - "reply": "敏感词检查完成。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": "report", - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 13, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 14, - "event_id": "", - "event_type": "skill_step_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.018Z", - "payload": { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "check_sensitive", - "to_step_id": "report", - "reason": "graph_merge_step", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 15, - "event_id": "", - "event_type": "graph_auto_progress_started", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.019Z", - "payload": { - "phase": "skill", - "text": "继续推进 SOP 分支", - "active_skill_id": "skill_parallel_audit", - "active_step_id": "report", - "pending_step_ids": [], - "iteration": 3, - "max_iterations": 6, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 16, - "event_id": "", - "event_type": "step_agent_result_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.020Z", - "payload": { - "action": "reply", - "reply": "并行审核报告已生成。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 17, - "event_id": "", - "event_type": "graph_pending_steps_updated", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.021Z", - "payload": { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 18, - "event_id": "", - "event_type": "reflection_decision_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.022Z", - "payload": { - "action": "pass", - "needs_retry": false, - "reason": "Scripted pass.", - "target_skill_id": null, - "target_step_id": null, - "target_tool_name": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 19, - "event_id": "", - "event_type": "skill_completed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.023Z", - "payload": { - "skill_id": "skill_parallel_audit", - "step_id": "report", - "reason": "step_completed", - "resumed_skill_id": null, - "resumed_step_id": null, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 20, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.026Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-gt04-merge" - } - }, - { - "observed_row_order": 21, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.027Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "并行检查这条报文", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/domain.json deleted file mode 100644 index 9b890311..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/domain.json +++ /dev/null @@ -1,355 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT04", - "variant_id": "GT04-merge", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:8ef9937ce7902a1165740d71c911f721f491a19a15da36ac6956c47aeb681619", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/0/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/20/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - }, - { - "name": "graph-transition-step-1-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_transition_step_id", - "plane": "domain", - "pointer": "/facts/0/step_transitions/0/to_step_id" - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": "/events/5/payload/to_step_id" - } - ] - }, - { - "name": "graph-transition-step-2-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_transition_step_id", - "plane": "domain", - "pointer": "/facts/0/step_transitions/1/to_step_id" - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": "/events/10/payload/to_step_id" - } - ] - }, - { - "name": "graph-transition-step-3-identity", - "rule_id": "legacy.graph_step_identity", - "references": [ - { - "role": "domain_transition_step_id", - "plane": "domain", - "pointer": "/facts/0/step_transitions/2/to_step_id" - }, - { - "role": "db_transition_step_id", - "plane": "db_events", - "pointer": "/events/14/payload/to_step_id" - } - ] - }, - { - "name": "graph-pending-snapshot-1-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": "/facts/0/pending_step_updates/0/pending_step_ids" - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": "/events/4/payload/pending_step_ids" - } - ] - }, - { - "name": "graph-pending-snapshot-2-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": "/facts/0/pending_step_updates/1/pending_step_ids" - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": "/events/8/payload/pending_step_ids" - } - ] - }, - { - "name": "graph-pending-snapshot-3-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": "/facts/0/pending_step_updates/2/pending_step_ids" - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": "/events/9/payload/pending_step_ids" - } - ] - }, - { - "name": "graph-pending-snapshot-4-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": "/facts/0/pending_step_updates/3/pending_step_ids" - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": "/events/13/payload/pending_step_ids" - } - ] - }, - { - "name": "graph-pending-snapshot-5-identity", - "rule_id": "legacy.graph_pending_identity", - "references": [ - { - "role": "domain_pending_step_ids", - "plane": "domain", - "pointer": "/facts/0/pending_step_updates/4/pending_step_ids" - }, - { - "role": "db_pending_step_ids", - "plane": "db_events", - "pointer": "/events/17/payload/pending_step_ids" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "并行检查这条报文。", - "client_turn_id": "client-gt04-merge", - "turn_id": "", - "session_id": "" - }, - "router_decision": { - "decision": "continue_active", - "selected_task_id": null, - "target_skill_id": "skill_parallel_audit", - "target_step_id": "report", - "confidence": 1.0, - "user_intent": "并行审核报文", - "general_intent": null, - "reason": "SOP 图还有可自动执行的后续节点。", - "source_message": "并行检查这条报文。", - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": "reply", - "reply": "并行审核报告已生成。", - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": true, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [ - { - "kind": "parallel_graph_merge", - "observation_source": "session_events_api", - "step_transitions": [ - { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "start", - "to_step_id": "check_payee", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "check_payee", - "to_step_id": "check_sensitive", - "reason": "graph_sibling_step", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "from_skill_id": "skill_parallel_audit", - "to_skill_id": "skill_parallel_audit", - "from_step_id": "check_sensitive", - "to_step_id": "report", - "reason": "graph_merge_step", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - ], - "pending_step_updates": [ - { - "pending_step_ids": [ - "check_sensitive" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "pending_step_ids": [ - "check_sensitive", - "report" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "pending_step_ids": [ - "report" - ], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - }, - { - "pending_step_ids": [], - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-gt04-merge" - } - ], - "auto_progress_count": 3, - "llm_phase_order": [ - "json:Router", - "json:Step Agent", - "json:Step Agent", - "json:Step Agent", - "json:Step Agent", - "json:Reflection", - "text:Response Generator" - ] - } - ], - "termination": "sync_response" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/provider.json deleted file mode 100644 index 87d4efec..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT04", - "variant_id": "GT04-merge", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT04-merge." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/sse.json deleted file mode 100644 index f1c911f3..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT04/GT04-merge/sse.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT04", - "variant_id": "GT04-merge", - "plane": "sse", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "sse is not_applicable for GT04-merge." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/conversation.json deleted file mode 100644 index a39b66be..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/conversation.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT13", - "variant_id": "GT13-llm-error", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:01f77bf6eabd457a2b509ff42e9828fd0257ff61f79625960ea2043557d57cb8", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": null, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "触发模型异常", - "metadata": { - "client_turn_id": "client-llm-error" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.021Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "触发模型异常", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.020Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.020Z" - }, - "interaction_checks": [ - { - "kind": "none", - "realtime_observation": "not_observed", - "refresh_observation": "not_observed", - "action_result": null - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/db_events.json deleted file mode 100644 index eb3acfbf..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/db_events.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT13", - "variant_id": "GT13-llm-error", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:d0265e18d7d73a9ea3442b23c0d38e2fcee64c37b64a8ba60b5cf46f6295f1b2", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/1/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/16/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "session_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "message_id": "", - "client_turn_id": "client-llm-error", - "message": "触发模型异常", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "error_occurred", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "code": "LLM_ERROR", - "message": "scripted failure at Router", - "client_turn_id": "client-llm-error", - "error_traceback": "Traceback (most recent call last):\n File \"/backend/app/core/agent_loop.py\", line , in handle_turn_stream\n router_decision = self.router.decide(\n ^^^^^^^^^^^^^^^^^^^\n File \"/backend/app/core/router.py\", line , in decide\n raw = LLMClient(model_config).generate_json(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/backend/tests/agent_golden/scripted_dependencies.py\", line , in generate_json\n self._raise_if_scripted(phase)\n File \"/backend/tests/agent_golden/scripted_dependencies.py\", line , in _raise_if_scripted\n raise LLMError(f\"scripted failure at {phase}\")\napp.llm.client.LLMError: scripted failure at Router\n", - "user_message_id": "", - "turn_id": "" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "phase": "error", - "text": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "code": "LLM_ERROR", - "message": "scripted failure at Router", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "content": "模型调用失败(L", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "content": "LM_ERROR", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "content": "):script", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "content": "ed failu", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "content": "re at Ro", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "content": "uter。请检查", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "payload": { - "content": "模型配置、API", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 12, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "content": " Key、网络或", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 13, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "content": "模型服务状态后重", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 14, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.018Z", - "payload": { - "content": "试。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 15, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.019Z", - "payload": { - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 16, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.022Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "observed_row_order": 17, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.023Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "触发模型异常", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-llm-error" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/domain.json deleted file mode 100644 index 6a9e1c89..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/domain.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT13", - "variant_id": "GT13-llm-error", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:7efdd8da9f24310d87f68e794e32c8bac8f17af7427f119273fec2ce675006df", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/1/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/16/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "触发模型异常", - "client_turn_id": "client-llm-error", - "turn_id": "" - }, - "router_decision": null, - "step_result": null, - "tool_result": null, - "outcome": { - "reply": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [], - "termination": "legacy_error_then_clean_close" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/provider.json deleted file mode 100644 index a090ed02..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT13", - "variant_id": "GT13-llm-error", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT13-llm-error." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/sse.json deleted file mode 100644 index dc1e8587..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT13/GT13-llm-error/sse.json +++ /dev/null @@ -1,672 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT13", - "variant_id": "GT13-llm-error", - "plane": "sse", - "applicability": "required", - "payload_schema": "planes/sse.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:0438957bf7f914b313da2d9d6ddab5869f84d0596533cbd94c262d5028ea306c", - "joins": [ - { - "name": "durable-session-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/0/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/0/event_id" - } - ] - }, - { - "name": "durable-user-message-received-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/1/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/1/event_id" - } - ] - }, - { - "name": "durable-status-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/2/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/2/event_id" - } - ] - }, - { - "name": "durable-error-occurred-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/3/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/3/event_id" - } - ] - }, - { - "name": "durable-status-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/4/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/4/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/5/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/5/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/6/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/6/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-3-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/7/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/7/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-4-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/8/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/8/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-5-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/9/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/9/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-6-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/10/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/10/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-7-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/11/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/11/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-8-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/12/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/12/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-9-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/13/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/13/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-10-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/14/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/14/event_id" - } - ] - }, - { - "name": "durable-stream-end-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/15/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/15/event_id" - } - ] - }, - { - "name": "durable-assistant-message-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/16/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/16/event_id" - } - ] - }, - { - "name": "durable-session-state-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/17/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/17/event_id" - } - ] - } - ], - "happens_before": [ - { - "name": "error-before-stream-end", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_error_sequence", - "plane": "sse", - "pointer": "/events/3/sequence" - }, - "after": { - "role": "sse_stream_end_sequence", - "plane": "sse", - "pointer": "/events/15/sequence" - } - }, - { - "name": "stream-end-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_stream_end_sequence", - "plane": "sse", - "pointer": "/events/15/sequence" - }, - "after": { - "role": "sse_assistant_event_sequence", - "plane": "sse", - "pointer": "/events/16/sequence" - } - } - ], - "payload": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": 200, - "content_type": "text/event-stream; charset=utf-8" - }, - "events": [ - { - "sequence": 0, - "id": "", - "event": "session_created", - "data": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "sequence": 1, - "id": "", - "event": "user_message_received", - "data": { - "kind": "user_message_received", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.005Z", - "provider": "skill", - "message_id": "", - "client_turn_id": "client-llm-error", - "message": "触发模型异常", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "sequence": 2, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.006Z", - "provider": "skill", - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 3, - "id": "", - "event": "error_occurred", - "data": { - "kind": "error_occurred", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.007Z", - "provider": "skill", - "code": "LLM_ERROR", - "message": "scripted failure at Router", - "client_turn_id": "client-llm-error", - "error_traceback": "Traceback (most recent call last):\n File \"/backend/app/core/agent_loop.py\", line , in handle_turn_stream\n router_decision = self.router.decide(\n ^^^^^^^^^^^^^^^^^^^\n File \"/backend/app/core/router.py\", line , in decide\n raw = LLMClient(model_config).generate_json(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/backend/tests/agent_golden/scripted_dependencies.py\", line , in generate_json\n self._raise_if_scripted(phase)\n File \"/backend/tests/agent_golden/scripted_dependencies.py\", line , in _raise_if_scripted\n raise LLMError(f\"scripted failure at {phase}\")\napp.llm.client.LLMError: scripted failure at Router\n", - "user_message_id": "", - "turn_id": "" - } - }, - { - "sequence": 4, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.008Z", - "provider": "skill", - "phase": "error", - "text": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "code": "LLM_ERROR", - "message": "scripted failure at Router", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 5, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.009Z", - "provider": "skill", - "content": "模型调用失败(L", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 6, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.010Z", - "provider": "skill", - "content": "LM_ERROR", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 7, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.011Z", - "provider": "skill", - "content": "):script", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 8, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.012Z", - "provider": "skill", - "content": "ed failu", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 9, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.013Z", - "provider": "skill", - "content": "re at Ro", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 10, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.014Z", - "provider": "skill", - "content": "uter。请检查", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 11, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "content": "模型配置、API", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 12, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.016Z", - "provider": "skill", - "content": " Key、网络或", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 13, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.017Z", - "provider": "skill", - "content": "模型服务状态后重", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 14, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.018Z", - "provider": "skill", - "content": "试。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 15, - "id": "", - "event": "stream_end", - "data": { - "kind": "stream_end", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.019Z", - "provider": "skill", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 16, - "id": "", - "event": "assistant_message_created", - "data": { - "kind": "assistant_message_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.022Z", - "provider": "skill", - "message_id": "", - "assistant_message_id": "", - "reply": "模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-llm-error" - } - }, - { - "sequence": 17, - "id": "", - "event": "session_state_changed", - "data": { - "kind": "session_state_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.023Z", - "provider": "skill", - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "触发模型异常", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:模型调用失败(LLM_ERROR):scripted failure at Router。请检查模型配置、API Key、网络或模型服务状态后重试。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-llm-error" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/conversation.json deleted file mode 100644 index 7389f693..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/conversation.json +++ /dev/null @@ -1,302 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT15", - "variant_id": "GT15-full", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:036949b578c2c4b71da3b2b890d27fd9ffb045d9fafe0c015bb24d69b449b0e2", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": null, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "先建立会话。", - "metadata": { - "client_turn_id": "client-draft-initial" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.002Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "每天九点检查 A1 价格。", - "metadata": { - "client_turn_id": "client-draft", - "interaction_mode": "scheduled_task" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "metadata": { - "scheduled_task_draft": { - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "" - }, - "user_message_id": "", - "turn_id": "", - "scheduled_task_created": { - "id": "", - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "created_by_user_id": "user_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": "FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0", - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "max_runs": null, - "end_at": null, - "next_run_at": "2000-01-01T00:00:00.022Z", - "last_run_at": null, - "last_status": null, - "run_count": 0, - "source_session_id": "", - "metadata": { - "created_from": "golden_confirmation" - }, - "created_at": "2000-01-01T00:00:00.021Z", - "updated_at": "2000-01-01T00:00:00.021Z" - } - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.014Z" - }, - "interaction_checks": [ - { - "kind": "scheduled_draft", - "realtime_observation": "transport_event_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "scheduled-draft-invalid-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm_invalid", - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "not-a-time" - }, - "timezone": "Asia/Shanghai", - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "source_session_id": "", - "metadata": { - "created_from": "golden_confirmation" - } - }, - "response_status": 400, - "persisted_state": { - "scheduled_task_created": null, - "task_count": 0 - } - } - }, - { - "kind": "scheduled_draft", - "realtime_observation": "transport_event_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "scheduled-draft-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm", - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "source_session_id": "", - "metadata": { - "created_from": "golden_confirmation" - } - }, - "response_status": 200, - "persisted_state": { - "created_id_matches_history": true, - "source_session_id": "", - "title": "每日检查价格", - "schedule": { - "time": "09:00" - }, - "task_count": 1 - } - } - }, - { - "kind": "scheduled_draft", - "realtime_observation": "transport_event_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "scheduled-draft-duplicate-confirm", - "evidence_origin": "harness_synthetic", - "resource_id": "scheduled-task-draft", - "action": "confirm_duplicate", - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "status": "active", - "concurrency_policy": "forbid", - "misfire_policy": "coalesce", - "source_session_id": "", - "metadata": { - "created_from": "golden_confirmation" - } - }, - "response_status": 200, - "persisted_state": { - "created_distinct_task": true, - "history_points_to_duplicate": true, - "task_count": 2 - } - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/db_events.json deleted file mode 100644 index a534d84f..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/db_events.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT15", - "variant_id": "GT15-full", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:584e64ec58eaf8b4e9acc950c7942119bb675dd8d9bd8a5cbf7cd5db60f3a52a", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/5/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/10/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "message_id": "", - "client_turn_id": "client-draft-initial", - "message": "先建立会话。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-draft-initial" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft-initial" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "phase": "scheduled_task_intent", - "text": "识别定时任务需求" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "phase": "scheduled_task_parse", - "text": "解析执行计划" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "message_id": "", - "client_turn_id": "client-draft", - "message": "每天九点检查 A1 价格。", - "channel": "web", - "user_id": "user_golden" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "phase": "scheduled_task_intent", - "text": "识别定时任务需求", - "user_message_id": "", - "turn_id": "" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.011Z", - "payload": { - "phase": "scheduled_task_parse", - "text": "解析执行计划", - "user_message_id": "", - "turn_id": "" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "payload": { - "phase": "scheduled_task_draft", - "text": "生成定时任务草案", - "user_message_id": "", - "turn_id": "", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "scheduled_task_draft_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "user_message_id": "", - "turn_id": "" - } - }, - { - "observed_row_order": 10, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "user_message_id": "", - "turn_id": "", - "reply": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "scheduled_task_draft": { - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "" - } - } - }, - { - "observed_row_order": 11, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.015Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "last_agent_question": null, - "status": "active" - } - }, - { - "observed_row_order": 12, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "phase": "scheduled_task_draft", - "text": "生成定时任务草案", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "observed_row_order": 13, - "event_id": "", - "event_type": "scheduled_task_draft", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.017Z", - "payload": { - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "observed_row_order": 14, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.018Z", - "payload": { - "content": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "observed_row_order": 15, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.019Z", - "payload": { - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "observed_row_order": 16, - "event_id": "", - "event_type": "complete", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.020Z", - "payload": { - "reply": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "session_id": "", - "router_decision": null, - "step_result": null, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "last_agent_question": null, - "status": "active" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/domain.json deleted file mode 100644 index d5c010df..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/domain.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT15", - "variant_id": "GT15-full", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:d485ed2a67b6d6b533762935b9ad07162a8e9c9a28390d0972179dabe164ae08", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/5/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/2/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/10/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/3/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "每天九点检查 A1 价格。", - "client_turn_id": "client-draft", - "turn_id": "", - "session_id": "", - "interaction_mode": "scheduled_task", - "client_timezone": "Asia/Shanghai" - }, - "router_decision": null, - "step_result": null, - "tool_result": null, - "outcome": { - "reply": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [], - "termination": "complete" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/provider.json deleted file mode 100644 index dd1a3809..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT15", - "variant_id": "GT15-full", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT15-full." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/sse.json deleted file mode 100644 index cdda67b7..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT15/GT15-full/sse.json +++ /dev/null @@ -1,615 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT15", - "variant_id": "GT15-full", - "plane": "sse", - "applicability": "required", - "payload_schema": "planes/sse.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:0e8910c3cb89315c3da53a5aef0f2ffd0944296d4a49d76967585d048c044c23", - "joins": [ - { - "name": "durable-status-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/0/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/3/event_id" - } - ] - }, - { - "name": "durable-status-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/1/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/4/event_id" - } - ] - }, - { - "name": "durable-user-message-received-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/2/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/5/event_id" - } - ] - }, - { - "name": "durable-status-event-3-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/3/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/6/event_id" - } - ] - }, - { - "name": "durable-status-event-4-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/4/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/7/event_id" - } - ] - }, - { - "name": "durable-status-event-5-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/5/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/8/event_id" - } - ] - }, - { - "name": "durable-scheduled-task-draft-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/6/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/9/event_id" - } - ] - }, - { - "name": "durable-assistant-message-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/7/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/10/event_id" - } - ] - }, - { - "name": "durable-session-state-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/8/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/11/event_id" - } - ] - }, - { - "name": "durable-status-event-6-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/9/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/12/event_id" - } - ] - }, - { - "name": "durable-scheduled-task-draft-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/10/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/13/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/11/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/14/event_id" - } - ] - }, - { - "name": "durable-stream-end-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/12/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/15/event_id" - } - ] - }, - { - "name": "durable-complete-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/13/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/16/event_id" - } - ] - } - ], - "happens_before": [ - { - "name": "user-event-before-complete", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_user_event_sequence", - "plane": "sse", - "pointer": "/events/2/sequence" - }, - "after": { - "role": "sse_complete_sequence", - "plane": "sse", - "pointer": "/events/13/sequence" - } - } - ], - "payload": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": 200, - "content_type": "text/event-stream; charset=utf-8" - }, - "events": [ - { - "sequence": 0, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.007Z", - "provider": "skill", - "phase": "scheduled_task_intent", - "text": "识别定时任务需求" - } - }, - { - "sequence": 1, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.008Z", - "provider": "skill", - "phase": "scheduled_task_parse", - "text": "解析执行计划" - } - }, - { - "sequence": 2, - "id": "", - "event": "user_message_received", - "data": { - "kind": "user_message_received", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.009Z", - "provider": "skill", - "message_id": "", - "client_turn_id": "client-draft", - "message": "每天九点检查 A1 价格。", - "channel": "web", - "user_id": "user_golden" - } - }, - { - "sequence": 3, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.010Z", - "provider": "skill", - "phase": "scheduled_task_intent", - "text": "识别定时任务需求", - "user_message_id": "", - "turn_id": "" - } - }, - { - "sequence": 4, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.011Z", - "provider": "skill", - "phase": "scheduled_task_parse", - "text": "解析执行计划", - "user_message_id": "", - "turn_id": "" - } - }, - { - "sequence": 5, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.012Z", - "provider": "skill", - "phase": "scheduled_task_draft", - "text": "生成定时任务草案", - "user_message_id": "", - "turn_id": "", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "" - } - }, - { - "sequence": 6, - "id": "", - "event": "scheduled_task_draft_created", - "data": { - "kind": "scheduled_task_draft_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.013Z", - "provider": "skill", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "user_message_id": "", - "turn_id": "" - } - }, - { - "sequence": 7, - "id": "", - "event": "assistant_message_created", - "data": { - "kind": "assistant_message_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.014Z", - "provider": "skill", - "message_id": "", - "assistant_message_id": "", - "user_message_id": "", - "turn_id": "", - "reply": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "scheduled_task_draft": { - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "" - } - } - }, - { - "sequence": 8, - "id": "", - "event": "session_state_changed", - "data": { - "kind": "session_state_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "last_agent_question": null, - "status": "active" - } - }, - { - "sequence": 9, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.016Z", - "provider": "skill", - "phase": "scheduled_task_draft", - "text": "生成定时任务草案", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "sequence": 10, - "id": "", - "event": "scheduled_task_draft", - "data": { - "kind": "scheduled_task_draft", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.017Z", - "provider": "skill", - "should_create": true, - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "title": "每日检查价格", - "prompt": "检查 A1 价格并汇总", - "description": null, - "schedule_type": "daily", - "schedule": { - "time": "09:00" - }, - "timezone": "Asia/Shanghai", - "rrule": null, - "confidence": 1.0, - "reason": "Golden scripted draft", - "source_session_id": "", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "sequence": 11, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.018Z", - "provider": "skill", - "content": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "sequence": 12, - "id": "", - "event": "stream_end", - "data": { - "kind": "stream_end", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.019Z", - "provider": "skill", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - }, - { - "sequence": 13, - "id": "", - "event": "complete", - "data": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.020Z", - "provider": "skill", - "reply": "我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "session_id": "", - "router_decision": null, - "step_result": null, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "先建立会话", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:我已按你选择的定时项目整理成自动任务草案。\n任务:每日检查价格\n计划:每天 09:00\n执行内容:检查 A1 价格并汇总\n确认下方卡片后才会启用;确认前不会创建自动任务。", - "last_agent_question": null, - "status": "active" - }, - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-draft" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/conversation.json b/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/conversation.json deleted file mode 100644 index 594d6976..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/conversation.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT16", - "variant_id": "GT16-history", - "plane": "conversation", - "applicability": "required", - "payload_schema": "planes/conversation.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:c1e3af0b7305599e20c60d86b205e3c38838d3e6c758388da8e632b0973c5613", - "joins": [], - "happens_before": [], - "payload": { - "sync_response": null, - "messages": [ - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "user", - "content": "请总结附件。", - "metadata": { - "client_turn_id": "client-attachment", - "attachments": [ - { - "id": "", - "filename": "golden-notes.txt", - "content_type": "text/plain", - "size": 19, - "kind": "text", - "text": "第一行\n第二行", - "preview": "第一行\n第二行", - "data_url": null, - "python_summary": "文件 golden-notes.txt,19 bytes,MIME text/plain。 解析得到 7 个字符、2 行、约 2 个词。", - "error": null - } - ] - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.004Z", - "feedback_rating": null - }, - { - "id": "", - "tenant_id": "tenant_golden", - "session_id": "", - "role": "assistant", - "content": "这是 Golden 测试的稳定回复。", - "metadata": { - "user_message_id": "", - "turn_id": "" - }, - "turn_id": "", - "created_at": "2000-01-01T00:00:00.012Z", - "feedback_rating": null - } - ], - "session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请总结附件", - "active_skill_id": null, - "active_step_id": null, - "status": "active", - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "is_scheduled": false, - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.011Z" - }, - "persisted_pre_state": null, - "persisted_session": { - "id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "status": "active", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "awaiting_input": null, - "pending_tasks": [], - "created_at": "2000-01-01T00:00:00.001Z", - "updated_at": "2000-01-01T00:00:00.011Z" - }, - "interaction_checks": [ - { - "kind": "attachment", - "realtime_observation": "not_observed", - "refresh_observation": "history_payload_observed", - "action_result": { - "evidence_id": "attachment-history-inspection", - "evidence_origin": "harness_synthetic", - "resource_id": "golden-notes.txt", - "action": "inspect_after_history", - "request": { - "filename": "golden-notes.txt" - }, - "response_status": 200, - "persisted_state": { - "attachment": { - "id": "", - "filename": "golden-notes.txt", - "content_type": "text/plain", - "size": 19, - "kind": "text", - "text": "第一行\n第二行", - "preview": "第一行\n第二行", - "data_url": null, - "python_summary": "文件 golden-notes.txt,19 bytes,MIME text/plain。 解析得到 7 个字符、2 行、约 2 个词。", - "error": null - } - } - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/db_events.json b/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/db_events.json deleted file mode 100644 index 20ce95b1..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/db_events.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT16", - "variant_id": "GT16-history", - "plane": "db_events", - "applicability": "required", - "payload_schema": "planes/db-events.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:129697728aa7d0480decbd71b9a59276a3141641d1bf75cf54fe32de75982496", - "joins": [], - "happens_before": [ - { - "name": "user-event-before-assistant-event", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "db_user_event_order", - "plane": "db_events", - "pointer": "/events/1/observed_row_order" - }, - "after": { - "role": "db_assistant_event_order", - "plane": "db_events", - "pointer": "/events/7/observed_row_order" - } - } - ], - "payload": { - "events": [ - { - "observed_row_order": 0, - "event_id": "", - "event_type": "session_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.003Z", - "payload": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "observed_row_order": 1, - "event_id": "", - "event_type": "user_message_received", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.005Z", - "payload": { - "message_id": "", - "client_turn_id": "client-attachment", - "message": "请总结附件。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "observed_row_order": 2, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.006Z", - "payload": { - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 3, - "event_id": "", - "event_type": "stream_status", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.007Z", - "payload": { - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 4, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.008Z", - "payload": { - "content": "这是 Golden ", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 5, - "event_id": "", - "event_type": "stream_delta", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.009Z", - "payload": { - "content": "测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 6, - "event_id": "", - "event_type": "stream_end", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.010Z", - "payload": { - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 7, - "event_id": "", - "event_type": "assistant_message_created", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.013Z", - "payload": { - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 8, - "event_id": "", - "event_type": "session_state_changed", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.014Z", - "payload": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请总结附件", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "observed_row_order": 9, - "event_id": "", - "event_type": "complete", - "tenant_id": "tenant_golden", - "session_id": "", - "created_at": "2000-01-01T00:00:00.016Z", - "payload": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请总结附件", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/domain.json b/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/domain.json deleted file mode 100644 index 64be5573..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/domain.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT16", - "variant_id": "GT16-history", - "plane": "domain", - "applicability": "required", - "payload_schema": "planes/domain.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:8bc6454d4a39101fd5500a528b11494ac832bd6875bfa3b94a323401729347d2", - "joins": [ - { - "name": "user-turn-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_turn_id", - "plane": "domain", - "pointer": "/request/turn_id" - }, - { - "role": "db_user_message_id", - "plane": "db_events", - "pointer": "/events/1/payload/message_id" - }, - { - "role": "conversation_user_message_id", - "plane": "conversation", - "pointer": "/messages/0/id" - } - ] - }, - { - "name": "assistant-message-identity", - "rule_id": "legacy.turn_message_identity", - "references": [ - { - "role": "domain_assistant_message_id", - "plane": "domain", - "pointer": "/outcome/assistant_message_id" - }, - { - "role": "db_assistant_message_id", - "plane": "db_events", - "pointer": "/events/7/payload/message_id" - }, - { - "role": "conversation_assistant_message_id", - "plane": "conversation", - "pointer": "/messages/1/id" - } - ] - }, - { - "name": "attachment-upload-request-history-identity", - "rule_id": "legacy.attachment_identity", - "references": [ - { - "role": "domain_request_attachment_id", - "plane": "domain", - "pointer": "/request/attachments/0/id" - }, - { - "role": "conversation_message_attachment_id", - "plane": "conversation", - "pointer": "/messages/0/metadata/attachments/0/id" - }, - { - "role": "action_persisted_attachment_id", - "plane": "conversation", - "pointer": "/interaction_checks/0/action_result/persisted_state/attachment/id" - } - ] - } - ], - "happens_before": [], - "payload": { - "request": { - "tenant_id": "tenant_golden", - "agent_id": "agent_golden", - "message": "请总结附件。", - "client_turn_id": "client-attachment", - "turn_id": "", - "attachments": [ - { - "id": "", - "filename": "golden-notes.txt", - "content_type": "text/plain", - "size": 19, - "kind": "text", - "text": "第一行\n第二行", - "preview": "第一行\n第二行", - "data_url": null, - "python_summary": "文件 golden-notes.txt,19 bytes,MIME text/plain。 解析得到 7 个字符、2 行、约 2 个词。", - "error": null - } - ] - }, - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "outcome": { - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "assistant_message_id": "" - }, - "facts": [], - "termination": "complete" - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/provider.json b/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/provider.json deleted file mode 100644 index d4380b3a..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/provider.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT16", - "variant_id": "GT16-history", - "plane": "provider", - "applicability": "not_applicable", - "payload_schema": null, - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "explicit_manifest", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": null - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": null, - "joins": [], - "happens_before": [], - "omission": { - "reason": "provider is not_applicable for GT16-history." - } -} diff --git a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/sse.json b/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/sse.json deleted file mode 100644 index c9face69..00000000 --- a/contracts/agent/v1/fixtures/legacy_characterization/GT16/GT16-history/sse.json +++ /dev/null @@ -1,451 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_set": "legacy_characterization", - "scenario_id": "GT16", - "variant_id": "GT16-history", - "plane": "sse", - "applicability": "required", - "payload_schema": "planes/sse.schema.json", - "normalization_profile": "agent-golden-v1", - "source": { - "kind": "captured_runtime", - "repo_revision": "ea475130dc3ba6dddd08d3456769d4062f73b75b", - "artifacts": [ - { - "path": "backend/tests/agent_golden/fixture_writer.py", - "sha256": "sha256:3784feb44fdc4fb4ee6b54a5ea420d453e82f4d5aaf33e55501b57d2ac118c96" - }, - { - "path": "backend/tests/agent_golden/legacy_scenario_capture.py", - "sha256": "sha256:dafdaa57a89d13163bb61bde9586583ac6d8c9811a7d59b68208c4115115dbc5" - }, - { - "path": "backend/tests/agent_golden/harness.py", - "sha256": "sha256:40f771c8c3ccc1cf59edb2759d04f4ac45de48996583253ffa7038faec128f6b" - }, - { - "path": "backend/tests/agent_golden/support.py", - "sha256": "sha256:8396ad2520dbeaa8aa4c796c39296dadd5465dc906c9d38596efdc29a6696869" - }, - { - "path": "backend/tests/agent_golden/scripted_dependencies.py", - "sha256": "sha256:3212379f91898a9d79d50d7e602d1666ad51d4b9fa1dd121bfaee054fa88c1f6" - }, - { - "path": "contracts/agent/v1/normalization-profiles.json", - "sha256": "sha256:8439f9fc9f269c0593d1768125ae9eaf71717260df676cc03d0ddf1c6e672c66" - }, - { - "path": "contracts/agent/v1/scenario-catalog.json", - "sha256": "sha256:9725d8e215fd2b528f0c8bc71a0ca007fa2c60b159856241f9f4ce2dd5fd3daa" - } - ], - "capture_harness": "agent_golden.fixture_writer.capture_legacy_envelopes" - }, - "legacy_field_observation": { - "interaction_id": "absent", - "run_id": "absent" - }, - "content_hash": "sha256:17e641cb1dca242eeaaa8882d5062ed45aabba3ec0d5da3aac2020f9b120a46a", - "joins": [ - { - "name": "durable-session-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/0/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/0/event_id" - } - ] - }, - { - "name": "durable-user-message-received-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/1/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/1/event_id" - } - ] - }, - { - "name": "durable-status-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/2/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/2/event_id" - } - ] - }, - { - "name": "durable-status-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/3/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/3/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/4/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/4/event_id" - } - ] - }, - { - "name": "durable-stream-delta-event-2-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/5/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/5/event_id" - } - ] - }, - { - "name": "durable-stream-end-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/6/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/6/event_id" - } - ] - }, - { - "name": "durable-assistant-message-created-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/7/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/7/event_id" - } - ] - }, - { - "name": "durable-session-state-changed-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/8/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/8/event_id" - } - ] - }, - { - "name": "durable-complete-event-identity", - "rule_id": "legacy.durable_event_identity", - "references": [ - { - "role": "sse_event_id", - "plane": "sse", - "pointer": "/events/9/id" - }, - { - "role": "db_event_id", - "plane": "db_events", - "pointer": "/events/9/event_id" - } - ] - } - ], - "happens_before": [ - { - "name": "user-event-before-complete", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": { - "role": "sse_user_event_sequence", - "plane": "sse", - "pointer": "/events/1/sequence" - }, - "after": { - "role": "sse_complete_sequence", - "plane": "sse", - "pointer": "/events/9/sequence" - } - } - ], - "payload": { - "http": { - "method": "POST", - "path": "/api/chat/stream", - "status": 200, - "content_type": "text/event-stream; charset=utf-8" - }, - "events": [ - { - "sequence": 0, - "id": "", - "event": "session_created", - "data": { - "kind": "session_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.002Z", - "provider": "skill", - "newSessionId": "" - } - }, - { - "sequence": 1, - "id": "", - "event": "user_message_received", - "data": { - "kind": "user_message_received", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.005Z", - "provider": "skill", - "message_id": "", - "client_turn_id": "client-attachment", - "message": "请总结附件。", - "channel": "web", - "user_id": "user_golden", - "turn_id": "", - "user_message_id": "" - } - }, - { - "sequence": 2, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.006Z", - "provider": "skill", - "phase": "routing", - "text": "正在判断用户意图", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 3, - "id": "", - "event": "status", - "data": { - "kind": "status", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.007Z", - "provider": "skill", - "phase": "responding", - "text": "正在生成回复", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 4, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.008Z", - "provider": "skill", - "content": "这是 Golden ", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 5, - "id": "", - "event": "stream_delta", - "data": { - "kind": "stream_delta", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.009Z", - "provider": "skill", - "content": "测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 6, - "id": "", - "event": "stream_end", - "data": { - "kind": "stream_end", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.010Z", - "provider": "skill", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 7, - "id": "", - "event": "assistant_message_created", - "data": { - "kind": "assistant_message_created", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.013Z", - "provider": "skill", - "message_id": "", - "assistant_message_id": "", - "reply": "这是 Golden 测试的稳定回复。", - "user_message_id": "", - "turn_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 8, - "id": "", - "event": "session_state_changed", - "data": { - "kind": "session_state_changed", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.014Z", - "provider": "skill", - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请总结附件", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active", - "turn_id": "", - "user_message_id": "", - "client_turn_id": "client-attachment" - } - }, - { - "sequence": 9, - "id": "", - "event": "complete", - "data": { - "kind": "complete", - "sessionId": "", - "timestamp": "2000-01-01T00:00:00.015Z", - "provider": "skill", - "reply": "这是 Golden 测试的稳定回复。", - "session_id": "", - "router_decision": { - "decision": "answer_only", - "selected_task_id": null, - "target_skill_id": null, - "target_step_id": null, - "confidence": 0.0, - "user_intent": null, - "general_intent": null, - "reason": "No published scene skills are available; answer as chat.", - "source_message": null, - "clarification_question": null, - "slot_hints": {}, - "task_frames": [], - "pending_tasks": [], - "task_updates": [], - "created_tasks": [], - "awaiting_input": null - }, - "step_result": { - "action": null, - "reply": null, - "slot_updates": {}, - "tool_call": null, - "knowledge_query": null, - "knowledge_results": [], - "next_step_id": null, - "is_step_completed": false, - "handoff": false - }, - "tool_result": null, - "session_state": { - "session_id": "", - "tenant_id": "tenant_golden", - "user_id": "user_golden", - "agent_id": "agent_golden", - "title": "请总结附件", - "active_skill_id": null, - "active_step_id": null, - "slots": {}, - "pending_tasks": [], - "awaiting_input": null, - "knowledge_context": [], - "summary": "最近回复:这是 Golden 测试的稳定回复。", - "last_agent_question": null, - "status": "active" - }, - "user_message_id": "", - "turn_id": "" - } - } - ] - } -} diff --git a/contracts/agent/v1/legacy-seams.json b/contracts/agent/v1/legacy-seams.json deleted file mode 100644 index ca773bb7..00000000 --- a/contracts/agent/v1/legacy-seams.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schema_version": "1.0", - "class": "app.core.agent_loop.AgentLoop", - "constructor_bypasses": [ - "app.core.agent_loop.GeneralSkillRunner", - "app.core.agent_loop.KnowledgeService", - "app.core.agent_loop.LLMClient" - ], - "graph_methods": [ - "_skill_steps", - "_skill_nodes", - "_ordered_skill_nodes", - "_graph_outgoing_edges", - "_next_steps_from_graph", - "_default_next_step", - "_graph_pending_steps", - "_store_graph_pending_steps", - "_queue_graph_sibling_steps", - "_activate_next_pending_graph_step", - "_is_terminal_skill_position", - "_is_answer_ready_skill_state", - "_graph_flow_has_unfinished_work" - ], - "call_topologies_to_characterize": [ - ["_skill_steps", "_ordered_skill_nodes", "_graph_outgoing_edges"], - ["_queue_graph_sibling_steps", "_graph_outgoing_edges", "_edge_condition"], - ["_activate_next_pending_graph_step", "_graph_pending_steps", "_store_graph_pending_steps"], - ["_should_complete_skill", "_is_answer_ready_skill_state", "_is_terminal_skill_state"] - ] -} diff --git a/contracts/agent/v1/legacy-skill-corpus.json b/contracts/agent/v1/legacy-skill-corpus.json deleted file mode 100644 index c29f6486..00000000 --- a/contracts/agent/v1/legacy-skill-corpus.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schema_version": "1.0", - "corpus_class": "production_seed", - "source_revision": "6c6594d42c08ae3757a72aa0e654a3fe555e4077", - "entries": [ - { - "skill_id": "skill_purchase_001", - "version": "1.0.0", - "source_path": "backend/app/db/seed.py", - "source_symbol": "PURCHASE_SKILL", - "fixture_path": "contracts/agent/v1/corpus/production_seed/purchase.json", - "content_hash": "sha256:e23eb10457c7b5e060f291159b036ed2ec7f2a9587842ce84880ea5b5250cd83" - }, - { - "skill_id": "skill_price_compare_001", - "version": "1.0.0", - "source_path": "backend/app/db/seed.py", - "source_symbol": "PRICE_COMPARE_SKILL", - "fixture_path": "contracts/agent/v1/corpus/production_seed/price_compare.json", - "content_hash": "sha256:f9944229f8e4e726a7b4b74d2afeca9317298cdc19e40d9f9b3d8f364eabc147" - }, - { - "skill_id": "after_sales_refund", - "version": "1.0.0", - "source_path": "backend/app/db/seed.py", - "source_symbol": "REFUND_SKILL", - "fixture_path": "contracts/agent/v1/corpus/production_seed/refund.json", - "content_hash": "sha256:e98c30da190ed9fb13110326ea88389cb69c811e7492b05f687e5663e217ca57" - } - ] -} diff --git a/contracts/agent/v1/manifest.json b/contracts/agent/v1/manifest.json deleted file mode 100644 index 1148f6a6..00000000 --- a/contracts/agent/v1/manifest.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "schema_version": "1.0", - "contract_version": "agent/v1", - "status": "phase_0_in_progress", - "schemas": [ - {"id": "manifest.schema.json", "path": "schemas/manifest.schema.json", "role": "asset"}, - {"id": "scenario-catalog.schema.json", "path": "schemas/scenario-catalog.schema.json", "role": "asset"}, - {"id": "requirement-registry.schema.json", "path": "schemas/requirement-registry.schema.json", "role": "asset"}, - {"id": "scenario-vocabulary.schema.json", "path": "schemas/scenario-vocabulary.schema.json", "role": "asset"}, - {"id": "field-ownership.schema.json", "path": "schemas/field-ownership.schema.json", "role": "asset"}, - {"id": "compatibility-matrix.schema.json", "path": "schemas/compatibility-matrix.schema.json", "role": "asset"}, - {"id": "legacy-seams.schema.json", "path": "schemas/legacy-seams.schema.json", "role": "asset"}, - {"id": "pairwise-manifest.schema.json", "path": "schemas/pairwise-manifest.schema.json", "role": "asset"}, - {"id": "legacy-skill-corpus.schema.json", "path": "schemas/legacy-skill-corpus.schema.json", "role": "asset"}, - {"id": "normalization-profiles.schema.json", "path": "schemas/normalization-profiles.schema.json", "role": "asset"}, - {"id": "relationship-requirements.schema.json", "path": "schemas/relationship-requirements.schema.json", "role": "asset"}, - {"id": "conformance-report.schema.json", "path": "schemas/conformance-report.schema.json", "role": "asset"}, - {"id": "fixture-envelope.schema.json", "path": "schemas/fixture-envelope.schema.json", "role": "fixture_envelope"}, - {"id": "planes/legacy-provider-exchange.schema.json", "path": "schemas/planes/legacy-provider-exchange.schema.json", "role": "fixture_payload"}, - {"id": "planes/domain.schema.json", "path": "schemas/planes/domain.schema.json", "role": "fixture_payload"}, - {"id": "planes/sse.schema.json", "path": "schemas/planes/sse.schema.json", "role": "fixture_payload"}, - {"id": "planes/db-events.schema.json", "path": "schemas/planes/db-events.schema.json", "role": "fixture_payload"}, - {"id": "planes/conversation.schema.json", "path": "schemas/planes/conversation.schema.json", "role": "fixture_payload"}, - {"id": "graph-characterization.schema.json", "path": "schemas/graph-characterization.schema.json", "role": "fixture_payload"}, - {"id": "interaction-block.schema.json", "path": "schemas/interaction-block.schema.json", "role": "target_conversation"}, - {"id": "conversation-projection.schema.json", "path": "schemas/conversation-projection.schema.json", "role": "target_conversation"} - ], - "instances": [ - {"path": "manifest.json", "schema": "manifest.schema.json"}, - {"path": "scenario-catalog.json", "schema": "scenario-catalog.schema.json"}, - {"path": "requirement-registry.json", "schema": "requirement-registry.schema.json"}, - {"path": "scenario-vocabulary.json", "schema": "scenario-vocabulary.schema.json"}, - {"path": "field-ownership.json", "schema": "field-ownership.schema.json"}, - {"path": "compatibility-matrix.json", "schema": "compatibility-matrix.schema.json"}, - {"path": "legacy-seams.json", "schema": "legacy-seams.schema.json"}, - {"path": "pairwise-manifest.json", "schema": "pairwise-manifest.schema.json"}, - {"path": "legacy-skill-corpus.json", "schema": "legacy-skill-corpus.schema.json"}, - {"path": "normalization-profiles.json", "schema": "normalization-profiles.schema.json"}, - {"path": "relationship-requirements.json", "schema": "relationship-requirements.schema.json"}, - {"path": "conformance-report-phase0.json", "schema": "conformance-report.schema.json"} - ], - "fixture_roots": { - "legacy_characterization": "fixtures/legacy_characterization", - "contract_v1": "fixtures/contract_v1" - }, - "deferred_provider_contracts": [ - {"service": "knowledge", "required_from_phase": "provider_contract_slice", "reason": "0A captures only the current Local raw exchange."}, - {"service": "scene_skill", "required_from_phase": "provider_contract_slice", "reason": "Scene Skill target contract follows Agent Core boundaries."}, - {"service": "general_skill", "required_from_phase": "provider_contract_slice", "reason": "Catalog and Executor require separate service contracts."} - ] -} diff --git a/contracts/agent/v1/normalization-profiles.json b/contracts/agent/v1/normalization-profiles.json deleted file mode 100644 index a78ec2de..00000000 --- a/contracts/agent/v1/normalization-profiles.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "schema_version": "1.0", - "profiles": [ - { - "id": "agent-golden-v1", - "sort_object_keys": true, - "preserve_array_order": true, - "preserve_nulls": true, - "preserve_duplicates": true, - "rules": [ - {"match": "**.session_id", "strategy": "identity_map"}, - {"match": "**.source_session_id", "strategy": "identity_map"}, - {"match": "**.sessionId", "strategy": "identity_map"}, - {"match": "**.newSessionId", "strategy": "identity_map"}, - {"match": "**.message_id", "strategy": "identity_map"}, - {"match": "**.user_message_id", "strategy": "identity_map"}, - {"match": "**.assistant_message_id", "strategy": "identity_map"}, - {"match": "**.turn_id", "strategy": "identity_map"}, - {"match": "**.event_id", "strategy": "identity_map"}, - {"match": "**.interaction_id", "strategy": "identity_map"}, - {"match": "**.run_id", "strategy": "identity_map"}, - {"match": "**.handoff_id", "strategy": "identity_map"}, - {"match": "**.draft_id", "strategy": "identity_map"}, - {"match": "$.sse.events[*].id", "strategy": "identity_map"}, - {"match": "$.conversation.messages[*].id", "strategy": "identity_map"}, - {"match": "$.conversation.session.id", "strategy": "identity_map"}, - {"match": "$.conversation.persisted_pre_state.id", "strategy": "identity_map"}, - {"match": "$.conversation.persisted_session.id", "strategy": "identity_map"}, - {"match": "$.domain.request.attachments[*].id", "strategy": "identity_map"}, - {"match": "$.db_events.events[*].payload.attachments[*].id", "strategy": "identity_map"}, - {"match": "$.conversation.messages[*].metadata.attachments[*].id", "strategy": "identity_map"}, - {"match": "$.conversation.interaction_checks[*].action_result.persisted_state.attachment.id", "strategy": "identity_map"}, - {"match": "$.conversation.interaction_checks[*].action_result.resource_id", "strategy": "identity_map", "qualifier": {"levels_up": 2, "relative_pointer": "/kind", "equals": "feedback"}}, - {"match": "$.conversation.interaction_checks[*].action_result.persisted_state.up_response.id", "strategy": "identity_map"}, - {"match": "$.conversation.messages[*].metadata.scheduled_task_created.id", "strategy": "identity_map"}, - {"match": "**.error_traceback", "strategy": "traceback_normalized"}, - {"match": "**.timestamp", "strategy": "monotonic_time_map"}, - {"match": "**.*_at", "strategy": "monotonic_time_map"}, - {"match": "**.created_at", "strategy": "monotonic_time_map"}, - {"match": "**.updated_at", "strategy": "monotonic_time_map"}, - {"match": "**.duration_ms", "strategy": "duration_placeholder"}, - {"match": "**.cursor", "strategy": "preserve"} - ] - } - ] -} diff --git a/contracts/agent/v1/pairwise-manifest.json b/contracts/agent/v1/pairwise-manifest.json deleted file mode 100644 index c8e0a299..00000000 --- a/contracts/agent/v1/pairwise-manifest.json +++ /dev/null @@ -1,436 +0,0 @@ -{ - "schema_version": "1.0", - "generator": { - "name": "staffdeck_deterministic_greedy_pairwise", - "version": "1.0.0", - "seed": 0, - "tie_breaker": "lexicographic_assignment", - "dimension_order": ["transport", "session", "outcome", "scenario", "action", "provider_boundary"] - }, - "status": "incomplete", - "common_dimensions": { - "transport": ["sync", "sse"], - "session": ["new", "existing", "refresh"], - "outcome": ["success", "partial", "failure", "timeout", "cancel"], - "scenario": ["none", "single_sop", "pending", "multi_frame"], - "action": ["none", "knowledge", "read_tool", "side_effect_tool", "general_skill"] - }, - "coverage_profiles": { - "0A_legacy": { - "status": "complete", - "required_from_phase": "0A", - "provider_boundary": ["not_applicable", "local_raw_exchange"], - "constraints": [ - { - "id": "LC01-plain-chat-sync", - "variant_id": "GT01-sync", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["none"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC02-plain-chat-sse", - "variant_id": "GT01-sse", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["none"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC03-runtime-failure-sse", - "variant_id": "GT13-llm-error", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["new", "existing", "refresh"], - "outcome": ["failure"], - "scenario": ["none"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC04-explicit-cancel-sse", - "variant_id": "GT12-cancel", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["new", "existing"], - "outcome": ["cancel"], - "scenario": ["none"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC05-single-sop-sync", - "variant_id": "GT03-true", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["single_sop"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC06-single-sop-sse", - "variant_id": "GT02-ask-refresh-continue", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["single_sop"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC07-pending-work-sync", - "variant_id": "GT05-pending", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["existing", "refresh"], - "outcome": ["partial"], - "scenario": ["pending"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC08-multi-frame-sse", - "variant_id": "GT05-multi-frame", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["existing", "refresh"], - "outcome": ["success"], - "scenario": ["multi_frame"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - }, - { - "id": "LC09-knowledge-chat-sse", - "variant_id": "GT07-citation-refresh", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["none"], - "action": ["knowledge"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC10-required-knowledge-sop-sync", - "variant_id": "GT08-query-before-advance", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["existing", "refresh"], - "outcome": ["success"], - "scenario": ["single_sop"], - "action": ["knowledge"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC11-read-tool-sop-sync", - "variant_id": "GT09-read", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["existing", "refresh"], - "outcome": ["success"], - "scenario": ["single_sop"], - "action": ["read_tool"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC12-side-effect-replay-sync", - "variant_id": "GT10-replay", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["existing", "refresh"], - "outcome": ["success"], - "scenario": ["none"], - "action": ["side_effect_tool"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC13-side-effect-unknown-sse", - "variant_id": "GT10-unknown", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["existing", "refresh"], - "outcome": ["partial", "timeout"], - "scenario": ["none"], - "action": ["side_effect_tool"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC14-general-skill-standalone-sync", - "variant_id": "GT11-standalone", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["new", "existing", "refresh"], - "outcome": ["success"], - "scenario": ["none"], - "action": ["general_skill"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC15-general-skill-sop-sse", - "variant_id": "GT11-sop", - "entrypoint": "chat_sse", - "allowed": { - "transport": ["sse"], - "session": ["existing", "refresh"], - "outcome": ["partial"], - "scenario": ["single_sop"], - "action": ["general_skill"], - "provider_boundary": ["local_raw_exchange"] - } - }, - { - "id": "LC16-sop-failure-sync", - "variant_id": "GT06-retry-limit", - "entrypoint": "chat_sync", - "allowed": { - "transport": ["sync"], - "session": ["existing", "refresh"], - "outcome": ["failure"], - "scenario": ["single_sop"], - "action": ["none"], - "provider_boundary": ["not_applicable"] - } - } - ], - "coverage": { - "strength": 2, - "legal_assignment_count": 41, - "legal_pair_count": 123, - "covered_pair_count": 123, - "coverage_digest": "sha256:90c4c3892f4881f8618ff2b143954f94619b354f770c50bb3514f6aa32ffc82d", - "require_each_constraint": true - }, - "cases": [ - { - "case_id": "PW0A-001", - "constraint_id": "LC04-explicit-cancel-sse", - "variant_id": "GT12-cancel", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "cancel", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-002", - "constraint_id": "LC15-general-skill-sop-sse", - "variant_id": "GT11-sop", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "refresh", "outcome": "partial", "scenario": "single_sop", "action": "general_skill", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-003", - "constraint_id": "LC12-side-effect-replay-sync", - "variant_id": "GT10-replay", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "success", "scenario": "none", "action": "side_effect_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-004", - "constraint_id": "LC05-single-sop-sync", - "variant_id": "GT03-true", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "new", "outcome": "success", "scenario": "single_sop", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-005", - "constraint_id": "LC07-pending-work-sync", - "variant_id": "GT05-pending", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "refresh", "outcome": "partial", "scenario": "pending", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-006", - "constraint_id": "LC09-knowledge-chat-sse", - "variant_id": "GT07-citation-refresh", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "new", "outcome": "success", "scenario": "none", "action": "knowledge", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-007", - "constraint_id": "LC13-side-effect-unknown-sse", - "variant_id": "GT10-unknown", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "refresh", "outcome": "timeout", "scenario": "none", "action": "side_effect_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-008", - "constraint_id": "LC08-multi-frame-sse", - "variant_id": "GT05-multi-frame", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "refresh", "outcome": "success", "scenario": "multi_frame", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-009", - "constraint_id": "LC16-sop-failure-sync", - "variant_id": "GT06-retry-limit", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "failure", "scenario": "single_sop", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-010", - "constraint_id": "LC11-read-tool-sop-sync", - "variant_id": "GT09-read", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "success", "scenario": "single_sop", "action": "read_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-011", - "constraint_id": "LC14-general-skill-standalone-sync", - "variant_id": "GT11-standalone", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "success", "scenario": "none", "action": "general_skill", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-012", - "constraint_id": "LC13-side-effect-unknown-sse", - "variant_id": "GT10-unknown", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "partial", "scenario": "none", "action": "side_effect_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-013", - "constraint_id": "LC03-runtime-failure-sse", - "variant_id": "GT13-llm-error", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "new", "outcome": "failure", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-014", - "constraint_id": "LC10-required-knowledge-sop-sync", - "variant_id": "GT08-query-before-advance", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "success", "scenario": "single_sop", "action": "knowledge", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-015", - "constraint_id": "LC08-multi-frame-sse", - "variant_id": "GT05-multi-frame", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "success", "scenario": "multi_frame", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-016", - "constraint_id": "LC13-side-effect-unknown-sse", - "variant_id": "GT10-unknown", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "timeout", "scenario": "none", "action": "side_effect_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-017", - "constraint_id": "LC04-explicit-cancel-sse", - "variant_id": "GT12-cancel", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "new", "outcome": "cancel", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-018", - "constraint_id": "LC03-runtime-failure-sse", - "variant_id": "GT13-llm-error", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "refresh", "outcome": "failure", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-019", - "constraint_id": "LC09-knowledge-chat-sse", - "variant_id": "GT07-citation-refresh", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "refresh", "outcome": "success", "scenario": "none", "action": "knowledge", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-020", - "constraint_id": "LC07-pending-work-sync", - "variant_id": "GT05-pending", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "partial", "scenario": "pending", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-021", - "constraint_id": "LC14-general-skill-standalone-sync", - "variant_id": "GT11-standalone", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "new", "outcome": "success", "scenario": "none", "action": "general_skill", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-022", - "constraint_id": "LC11-read-tool-sop-sync", - "variant_id": "GT09-read", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "refresh", "outcome": "success", "scenario": "single_sop", "action": "read_tool", "provider_boundary": "local_raw_exchange"} - }, - { - "case_id": "PW0A-023", - "constraint_id": "LC01-plain-chat-sync", - "variant_id": "GT01-sync", - "entrypoint": "chat_sync", - "values": {"transport": "sync", "session": "existing", "outcome": "success", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-024", - "constraint_id": "LC02-plain-chat-sse", - "variant_id": "GT01-sse", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "success", "scenario": "none", "action": "none", "provider_boundary": "not_applicable"} - }, - { - "case_id": "PW0A-025", - "constraint_id": "LC06-single-sop-sse", - "variant_id": "GT02-ask-refresh-continue", - "entrypoint": "chat_sse", - "values": {"transport": "sse", "session": "existing", "outcome": "success", "scenario": "single_sop", "action": "none", "provider_boundary": "not_applicable"} - } - ], - "missing_pairs_are_gate_failures": true - }, - "provider_contract": { - "status": "deferred", - "required_from_phase": "provider_contract_slice", - "provider_boundary": ["local_contract", "fake_remote_contract"], - "constraints": [], - "coverage": null, - "cases": [], - "missing_pairs_are_gate_failures": true - } - }, - "notes": [ - "The 0A gate evaluates only the 0A_legacy profile.", - "Constraints are an allow-list of complete assignment families; every legal assignment matches exactly one stable constraint and existing catalog variant.", - "Fake Remote coverage cannot block 0A because service-specific Provider contracts are deferred.", - "Pairwise complements, but does not replace, exhaustive cancellation, idempotency, reconciliation, and CAS scenarios." - ] -} diff --git a/contracts/agent/v1/relationship-requirements.json b/contracts/agent/v1/relationship-requirements.json deleted file mode 100644 index dab31241..00000000 --- a/contracts/agent/v1/relationship-requirements.json +++ /dev/null @@ -1,214 +0,0 @@ -{ - "schema_version": "1.0", - "reference_profiles": [ - { - "id": "legacy.user-turn-message-identity", - "kind": "join", - "rule_id": "legacy.turn_message_identity", - "references": [ - {"role": "domain_turn_id", "plane": "domain", "pointer_pattern": "/request/turn_id"}, - {"role": "db_user_message_id", "plane": "db_events", "pointer_pattern": "/events/*/payload/message_id", "qualifier": {"levels_up": 2, "relative_pointer": "/event_type", "equals": "user_message_received"}}, - {"role": "conversation_user_message_id", "plane": "conversation", "pointer_pattern": "/messages/*/id", "qualifier": {"levels_up": 1, "relative_pointer": "/role", "equals": "user"}} - ] - }, - { - "id": "legacy.assistant-message-identity", - "kind": "join", - "rule_id": "legacy.turn_message_identity", - "references": [ - {"role": "domain_assistant_message_id", "plane": "domain", "pointer_pattern": "/outcome/assistant_message_id"}, - {"role": "db_assistant_message_id", "plane": "db_events", "pointer_pattern": "/events/*/payload/message_id", "qualifier": {"levels_up": 2, "relative_pointer": "/event_type", "equals": "assistant_message_created"}}, - {"role": "conversation_assistant_message_id", "plane": "conversation", "pointer_pattern": "/messages/*/id", "qualifier": {"levels_up": 1, "relative_pointer": "/role", "equals": "assistant"}} - ] - }, - { - "id": "legacy.attachment-identity", - "kind": "join", - "rule_id": "legacy.attachment_identity", - "references": [ - {"role": "domain_request_attachment_id", "plane": "domain", "pointer_pattern": "/request/attachments/*/id"}, - {"role": "conversation_message_attachment_id", "plane": "conversation", "pointer_pattern": "/messages/*/metadata/attachments/*/id", "qualifier": {"levels_up": 4, "relative_pointer": "/role", "equals": "user"}}, - {"role": "action_persisted_attachment_id", "plane": "conversation", "pointer_pattern": "/interaction_checks/*/action_result/persisted_state/attachment/id", "qualifier": {"levels_up": 4, "relative_pointer": "/kind", "equals": "attachment"}} - ] - }, - { - "id": "legacy.feedback-target-message-identity", - "kind": "join", - "rule_id": "legacy.feedback_target_identity", - "references": [ - {"role": "conversation_assistant_message_id", "plane": "conversation", "pointer_pattern": "/messages/*/id", "qualifier": {"levels_up": 1, "relative_pointer": "/role", "equals": "assistant"}}, - {"role": "action_feedback_target_id", "plane": "conversation", "pointer_pattern": "/interaction_checks/*/action_result/resource_id", "qualifier": {"levels_up": 2, "relative_pointer": "/kind", "equals": "feedback"}}, - {"role": "feedback_response_message_id", "plane": "conversation", "pointer_pattern": "/interaction_checks/*/action_result/persisted_state/up_response/message_id", "qualifier": {"levels_up": 4, "relative_pointer": "/kind", "equals": "feedback"}} - ] - }, - { - "id": "legacy.graph-selected-step-identity", - "kind": "join", - "rule_id": "legacy.graph_step_identity", - "references": [ - {"role": "domain_selected_step_id", "plane": "domain", "pointer_pattern": "/facts/*/selected_step_id"}, - {"role": "db_transition_step_id", "plane": "db_events", "pointer_pattern": "/events/*/payload/to_step_id", "qualifier": {"levels_up": 2, "relative_pointer": "/event_type", "equals": "skill_step_changed"}} - ] - }, - { - "id": "legacy.graph-transition-step-identity", - "kind": "join", - "rule_id": "legacy.graph_step_identity", - "references": [ - {"role": "domain_transition_step_id", "plane": "domain", "pointer_pattern": "/facts/*/step_transitions/*/to_step_id"}, - {"role": "db_transition_step_id", "plane": "db_events", "pointer_pattern": "/events/*/payload/to_step_id", "qualifier": {"levels_up": 2, "relative_pointer": "/event_type", "equals": "skill_step_changed"}} - ] - }, - { - "id": "legacy.graph-pending-snapshot-identity", - "kind": "join", - "rule_id": "legacy.graph_pending_identity", - "references": [ - {"role": "domain_pending_step_ids", "plane": "domain", "pointer_pattern": "/facts/*/pending_step_updates/*/pending_step_ids"}, - {"role": "db_pending_step_ids", "plane": "db_events", "pointer_pattern": "/events/*/payload/pending_step_ids", "qualifier": {"levels_up": 2, "relative_pointer": "/event_type", "equals": "graph_pending_steps_updated"}} - ] - }, - { - "id": "legacy.db-user-before-assistant", - "kind": "happens_before", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": {"role": "db_user_event_order", "plane": "db_events", "pointer_pattern": "/events/*/observed_row_order", "qualifier": {"levels_up": 1, "relative_pointer": "/event_type", "equals": "user_message_received"}}, - "after": {"role": "db_assistant_event_order", "plane": "db_events", "pointer_pattern": "/events/*/observed_row_order", "qualifier": {"levels_up": 1, "relative_pointer": "/event_type", "equals": "assistant_message_created"}} - }, - { - "id": "legacy.sse-user-before-complete", - "kind": "happens_before", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": {"role": "sse_user_event_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "user_message_received"}}, - "after": {"role": "sse_complete_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "complete"}} - }, - { - "id": "legacy.sse-error-before-stream-end", - "kind": "happens_before", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": {"role": "sse_error_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "error_occurred"}}, - "after": {"role": "sse_stream_end_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "stream_end"}} - }, - { - "id": "legacy.sse-stream-end-before-assistant", - "kind": "happens_before", - "rule_id": "legacy.observed_event_order", - "order_type": "integer", - "before": {"role": "sse_stream_end_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "stream_end"}}, - "after": {"role": "sse_assistant_event_sequence", "plane": "sse", "pointer_pattern": "/events/*/sequence", "qualifier": {"levels_up": 1, "relative_pointer": "/event", "equals": "assistant_message_created"}} - } - ], - "variants": [ - { - "fixture_set": "legacy_characterization", - "variant_id": "GT01-sync", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT01-sse", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"}, - {"kind": "happens_before", "plane": "sse", "name": "user-event-before-complete", "profile_id": "legacy.sse-user-before-complete"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT01-feedback-refresh-toggle", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "join", "plane": "conversation", "name": "feedback-target-message-identity", "profile_id": "legacy.feedback-target-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT02-ask-refresh-continue", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"}, - {"kind": "happens_before", "plane": "sse", "name": "user-event-before-complete", "profile_id": "legacy.sse-user-before-complete"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT03-true", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "join", "plane": "domain", "name": "selected-graph-step-identity", "profile_id": "legacy.graph-selected-step-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT03-false", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "join", "plane": "domain", "name": "selected-graph-step-identity", "profile_id": "legacy.graph-selected-step-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT04-merge", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-transition-step-1-identity", "profile_id": "legacy.graph-transition-step-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-transition-step-2-identity", "profile_id": "legacy.graph-transition-step-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-transition-step-3-identity", "profile_id": "legacy.graph-transition-step-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-pending-snapshot-1-identity", "profile_id": "legacy.graph-pending-snapshot-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-pending-snapshot-2-identity", "profile_id": "legacy.graph-pending-snapshot-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-pending-snapshot-3-identity", "profile_id": "legacy.graph-pending-snapshot-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-pending-snapshot-4-identity", "profile_id": "legacy.graph-pending-snapshot-identity"}, - {"kind": "join", "plane": "domain", "name": "graph-pending-snapshot-5-identity", "profile_id": "legacy.graph-pending-snapshot-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT13-llm-error", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"}, - {"kind": "happens_before", "plane": "sse", "name": "error-before-stream-end", "profile_id": "legacy.sse-error-before-stream-end"}, - {"kind": "happens_before", "plane": "sse", "name": "stream-end-before-assistant-event", "profile_id": "legacy.sse-stream-end-before-assistant"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT15-full", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"}, - {"kind": "happens_before", "plane": "sse", "name": "user-event-before-complete", "profile_id": "legacy.sse-user-before-complete"} - ] - }, - { - "fixture_set": "legacy_characterization", - "variant_id": "GT16-history", - "relations": [ - {"kind": "join", "plane": "domain", "name": "user-turn-message-identity", "profile_id": "legacy.user-turn-message-identity"}, - {"kind": "join", "plane": "domain", "name": "assistant-message-identity", "profile_id": "legacy.assistant-message-identity"}, - {"kind": "join", "plane": "domain", "name": "attachment-upload-request-history-identity", "profile_id": "legacy.attachment-identity"}, - {"kind": "happens_before", "plane": "db_events", "name": "user-event-before-assistant-event", "profile_id": "legacy.db-user-before-assistant"}, - {"kind": "happens_before", "plane": "sse", "name": "user-event-before-complete", "profile_id": "legacy.sse-user-before-complete"} - ] - } - ] -} diff --git a/contracts/agent/v1/requirement-registry.json b/contracts/agent/v1/requirement-registry.json deleted file mode 100644 index d93d4701..00000000 --- a/contracts/agent/v1/requirement-registry.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schema_version": "1.0", - "requirements": [ - {"id": "AGENT-UX-REPLY-PARITY", "title": "Sync and SSE final reply parity", "phase": "0A", "assertion": "Normalized sync reply and streamed terminal reply are equal."}, - {"id": "AGENT-UX-SSE-ORDER", "title": "Legacy SSE order", "phase": "0A", "assertion": "Required legacy SSE events and terminal ordering are characterized without reordering."}, - {"id": "AGENT-UX-SOP-RESUME", "title": "SOP refresh and resume", "phase": "0A", "assertion": "A waiting SOP remains visible and can continue after History reload."}, - {"id": "AGENT-GRAPH-BRANCH", "title": "Conditional graph branches", "phase": "0A", "assertion": "Both true and false legacy branch outcomes are characterized."}, - {"id": "AGENT-GRAPH-PENDING-SIBLING", "title": "Parallel sibling ordering", "phase": "0A", "assertion": "Pending sibling order and merge state are stable."}, - {"id": "AGENT-SCENARIO-PENDING", "title": "Pending task resume", "phase": "0A", "assertion": "Completing active work resumes the expected pending task without state loss."}, - {"id": "AGENT-SCENARIO-MULTI-FRAME", "title": "Multiple frame ordering", "phase": "0A", "assertion": "Multiple legacy frames execute in their characterized order."}, - {"id": "AGENT-REFLECTION-RETRY", "title": "Reflection retry success", "phase": "0A", "assertion": "A repairable result follows the characterized retry path."}, - {"id": "AGENT-REFLECTION-LIMIT", "title": "Reflection retry limit", "phase": "0A", "assertion": "An unrepairable result stops at the characterized retry limit."}, - {"id": "AGENT-KNOWLEDGE-CITATION", "title": "Knowledge citation recovery", "phase": "0A", "assertion": "Citation content and action remain available after History reload."}, - {"id": "AGENT-KNOWLEDGE-REQUIRED", "title": "Required knowledge before advance", "phase": "0A", "assertion": "A required knowledge node queries before graph advance."}, - {"id": "AGENT-TOOL-READ", "title": "Read-only tool result", "phase": "0A", "assertion": "Read-only tool result, event, and resulting state are characterized."}, - {"id": "AGENT-TOOL-IDEMPOTENCY", "title": "Side-effect tool replay", "phase": "0A", "assertion": "A repeated side-effect signature reuses the characterized result."}, - {"id": "AGENT-TOOL-UNKNOWN", "title": "Unknown side-effect status", "phase": "0A", "assertion": "A lost side-effect response is not blindly replayed."}, - {"id": "AGENT-SKILL-STANDALONE", "title": "Standalone general skill", "phase": "0A", "assertion": "Standalone General Skill selection, execution, and reply are characterized."}, - {"id": "AGENT-SKILL-IN-SOP", "title": "General skill inside SOP", "phase": "0A", "assertion": "General Skill execution returns to the active SOP without losing state."}, - {"id": "AGENT-CANCEL-DISCONNECT", "title": "Transport disconnect semantics", "phase": "0A", "assertion": "Transport disconnect is distinguished from explicit cancellation."}, - {"id": "AGENT-RELAY-RESUME", "title": "Relay recovery", "phase": "0C", "assertion": "A detached client resumes committed events without duplicate execution."}, - {"id": "AGENT-CANCEL-EXPLICIT", "title": "Explicit cancellation", "phase": "0A", "assertion": "Explicit cancellation produces one stable terminal projection."}, - {"id": "AGENT-ERROR-STABLE", "title": "Stable runtime failure", "phase": "0A", "assertion": "Runtime failure produces characterized durable and user-visible output."}, - {"id": "AGENT-HANDOFF-CREATE", "title": "Handoff creation", "phase": "0A", "assertion": "Declared handoff creates one pending request and stable conversation state."}, - {"id": "AGENT-HANDOFF-REFRESH", "title": "Handoff refresh", "phase": "0A", "assertion": "Pending handoff is visible after reload."}, - {"id": "AGENT-HANDOFF-REPLY-IDEMPOTENCY", "title": "Handoff reply idempotency", "phase": "0A", "assertion": "Legacy handoff reply behavior and duplicate handling are characterized."}, - {"id": "AGENT-HANDOFF-RESUME", "title": "Handoff resume", "phase": "0A", "assertion": "Replying resumes the original Session and persists terminal state."}, - {"id": "AGENT-DRAFT-REFRESH", "title": "Scheduled draft refresh", "phase": "0A", "assertion": "Scheduled draft remains actionable after History reload."}, - {"id": "AGENT-DRAFT-CONFIRM-IDEMPOTENCY", "title": "Scheduled draft confirmation", "phase": "0A", "assertion": "Legacy scheduled draft confirmation behavior is characterized by source message."}, - {"id": "AGENT-ATTACHMENT-HISTORY", "title": "Attachment History recovery", "phase": "0A", "assertion": "Attachment public metadata survives History reload."}, - {"id": "AGENT-ENTRY-CHANNEL", "title": "Channel projection", "phase": "0A", "assertion": "Channel ingress produces the characterized Message, Event, and Outbox projection."}, - {"id": "AGENT-ENTRY-SCHEDULED", "title": "Scheduled worker projection", "phase": "0A", "assertion": "Scheduled worker execution produces the characterized conversation projection."}, - {"id": "AGENT-FEEDBACK-REFRESH", "title": "Persisted feedback controls", "phase": "0A", "assertion": "Feedback binds to a persisted assistant Message, survives History reload, supports POST and DELETE, and rolls UI state back on command failure."} - ] -} diff --git a/contracts/agent/v1/scenario-catalog.json b/contracts/agent/v1/scenario-catalog.json deleted file mode 100644 index 6818a07a..00000000 --- a/contracts/agent/v1/scenario-catalog.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "schema_version": "1.0", - "fixture_roots": { - "legacy": "fixtures/legacy_characterization", - "contract_v1": "fixtures/contract_v1" - }, - "fixture_path_template": "{fixture_root}/{scenario_id}/{fixture_key}/{plane}.json", - "scenarios": [ - { - "id": "GT01", - "name": "plain_chat_sync_and_sse", - "variants": [ - {"variant_id": "GT01-sync", "fixture_key": "GT01-sync", "entrypoint": "chat_sync", "pre_state": "new_session", "steps": ["send"], "requirements": ["AGENT-UX-REPLY-PARITY"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT01-sse", "fixture_key": "GT01-sse", "entrypoint": "chat_sse", "pre_state": "new_session", "steps": ["send", "relay"], "requirements": ["AGENT-UX-REPLY-PARITY", "AGENT-UX-SSE-ORDER"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT01-feedback-refresh-toggle", "fixture_key": "GT01-feedback-refresh-toggle", "entrypoint": "message_feedback", "pre_state": "persisted_assistant_message", "steps": ["history_read", "feedback_up_api", "feedback_clear_api", "feedback_failure_rollback"], "requirements": ["AGENT-FEEDBACK-REFRESH"], "refresh_action": "feedback_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ] - }, - {"id": "GT02", "name": "single_sop_ask_refresh_continue", "variants": [{"variant_id": "GT02-ask-refresh-continue", "fixture_key": "GT02-ask-refresh-continue", "entrypoint": "chat_sse", "pre_state": "new_sop_session", "steps": ["ask", "history_read", "continue"], "requirements": ["AGENT-UX-SOP-RESUME"], "refresh_action": "continue_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT03", "name": "conditional_sop_both_branches", "variants": [ - {"variant_id": "GT03-true", "fixture_key": "GT03-true", "entrypoint": "chat_sync", "pre_state": "condition_step", "steps": ["select_true_branch"], "requirements": ["AGENT-GRAPH-BRANCH"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT03-false", "fixture_key": "GT03-false", "entrypoint": "chat_sync", "pre_state": "condition_step", "steps": ["select_false_branch"], "requirements": ["AGENT-GRAPH-BRANCH"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT04", "name": "parallel_sibling_merge", "variants": [{"variant_id": "GT04-merge", "fixture_key": "GT04-merge", "entrypoint": "chat_sync", "pre_state": "parallel_start", "steps": ["first_sibling", "second_sibling", "merge"], "requirements": ["AGENT-GRAPH-PENDING-SIBLING"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT05", "name": "pending_and_multi_frame", "variants": [ - {"variant_id": "GT05-pending", "fixture_key": "GT05-pending", "entrypoint": "chat_sync", "pre_state": "active_plus_pending", "steps": ["complete_active", "resume_pending"], "requirements": ["AGENT-SCENARIO-PENDING"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "waiting"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT05-multi-frame", "fixture_key": "GT05-multi-frame", "entrypoint": "chat_sse", "pre_state": "multiple_turn_frames", "steps": ["execute_frames_in_order"], "requirements": ["AGENT-SCENARIO-MULTI-FRAME"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT06", "name": "reflection_retry", "variants": [ - {"variant_id": "GT06-retry-success", "fixture_key": "GT06-retry-success", "entrypoint": "chat_sync", "pre_state": "repairable_result", "steps": ["reflect", "retry", "complete"], "requirements": ["AGENT-REFLECTION-RETRY"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT06-retry-limit", "fixture_key": "GT06-retry-limit", "entrypoint": "chat_sync", "pre_state": "unrepairable_result", "steps": ["reflect_until_limit"], "requirements": ["AGENT-REFLECTION-LIMIT"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "failed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT07", "name": "knowledge_with_citation", "variants": [{"variant_id": "GT07-citation-refresh", "fixture_key": "GT07-citation-refresh", "entrypoint": "chat_sse", "pre_state": "knowledge_available", "steps": ["search", "stream_citation", "history_read"], "requirements": ["AGENT-KNOWLEDGE-CITATION"], "refresh_action": "open_citation_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT08", "name": "required_knowledge_node", "variants": [{"variant_id": "GT08-query-before-advance", "fixture_key": "GT08-query-before-advance", "entrypoint": "chat_sync", "pre_state": "required_knowledge_step", "steps": ["normalize_query", "search", "advance"], "requirements": ["AGENT-KNOWLEDGE-REQUIRED"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT09", "name": "read_only_tool", "variants": [{"variant_id": "GT09-read", "fixture_key": "GT09-read", "entrypoint": "chat_sync", "pre_state": "tool_step", "steps": ["execute_read", "advance"], "requirements": ["AGENT-TOOL-READ"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT10", "name": "side_effect_tool_replay_and_unknown", "variants": [ - {"variant_id": "GT10-replay", "fixture_key": "GT10-replay", "entrypoint": "chat_sync", "pre_state": "existing_successful_effect", "steps": ["request_same_effect", "reuse"], "requirements": ["AGENT-TOOL-IDEMPOTENCY"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT10-unknown", "fixture_key": "GT10-unknown", "entrypoint": "chat_sse", "pre_state": "effect_status_unknown", "steps": ["start", "lose_response", "reconcile"], "requirements": ["AGENT-TOOL-UNKNOWN"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "waiting"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT11", "name": "general_skill_standalone_and_sop", "variants": [ - {"variant_id": "GT11-standalone", "fixture_key": "GT11-standalone", "entrypoint": "chat_sync", "pre_state": "no_active_sop", "steps": ["select_skill", "execute", "reply"], "requirements": ["AGENT-SKILL-STANDALONE"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT11-sop", "fixture_key": "GT11-sop", "entrypoint": "chat_sse", "pre_state": "active_sop", "steps": ["select_skill", "execute", "continue_sop"], "requirements": ["AGENT-SKILL-IN-SOP"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "waiting"}, "planes": {"provider": {"legacy": "required", "contract_v1": "deferred"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT12", "name": "disconnect_and_explicit_cancel", "variants": [ - {"variant_id": "GT12-disconnect", "fixture_key": "GT12-disconnect", "entrypoint": "chat_sse", "pre_state": "running_stream", "steps": ["disconnect_transport", "await_terminal", "history_read"], "legacy_steps": ["disconnect_transport", "events_poll", "await_terminal", "history_read"], "contract_v1_steps": ["disconnect_transport", "reconnect_relay_after_cursor", "await_terminal", "history_read"], "requirements": ["AGENT-CANCEL-DISCONNECT", "AGENT-RELAY-RESUME"], "refresh_action": "reconnect_then_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed", "cancel_state": "none", "duplicate_execution": false}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT12-cancel", "fixture_key": "GT12-cancel", "entrypoint": "chat_sse", "pre_state": "running_stream", "steps": ["explicit_cancel"], "requirements": ["AGENT-CANCEL-EXPLICIT"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "cancelled"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT13", "name": "runtime_failure", "variants": [{"variant_id": "GT13-llm-error", "fixture_key": "GT13-llm-error", "entrypoint": "chat_sse", "pre_state": "configured_model", "steps": ["raise_llm_error"], "requirements": ["AGENT-ERROR-STABLE"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "failed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT14", "name": "handoff_refresh_reply_resume", "variants": [ - {"variant_id": "GT14-create", "fixture_key": "GT14-create", "entrypoint": "chat_sse", "pre_state": "declared_handoff_step", "steps": ["send", "create_handoff", "history_read"], "requirements": ["AGENT-HANDOFF-CREATE", "AGENT-HANDOFF-REFRESH"], "refresh_action": "open_handoff_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "handoff"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT14-reply-resume", "fixture_key": "GT14-reply-resume", "entrypoint": "handoff_resume", "pre_state": "pending_handoff", "steps": ["history_read", "reply_api", "resume_worker", "events_poll", "history_read"], "requirements": ["AGENT-HANDOFF-REPLY-IDEMPOTENCY", "AGENT-HANDOFF-RESUME"], "refresh_action": "reply_then_poll_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]}, - {"id": "GT15", "name": "scheduled_draft_refresh_confirm", "variants": [{"variant_id": "GT15-full", "fixture_key": "GT15-full", "entrypoint": "chat_sse", "interaction_mode": "scheduled_task", "pre_state": "draft_detected", "steps": ["stream_draft", "terminal_projection", "history_read", "confirm_draft_api"], "requirements": ["AGENT-DRAFT-REFRESH", "AGENT-DRAFT-CONFIRM-IDEMPOTENCY"], "refresh_action": "confirm_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT16", "name": "attachment_history_recovery", "variants": [{"variant_id": "GT16-history", "fixture_key": "GT16-history", "entrypoint": "chat_sse", "pre_state": "attachment_uploaded", "steps": ["send_attachment", "history_read"], "requirements": ["AGENT-ATTACHMENT-HISTORY"], "refresh_action": "inspect_attachment_after_history", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "required", "contract_v1": "required"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}]}, - {"id": "GT17", "name": "channel_and_scheduled_projection", "variants": [ - {"variant_id": "GT17-channel", "fixture_key": "GT17-channel", "entrypoint": "channel", "pre_state": "bound_channel", "steps": ["ingress", "execute", "outbox"], "requirements": ["AGENT-ENTRY-CHANNEL"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}}, - {"variant_id": "GT17-scheduled", "fixture_key": "GT17-scheduled", "entrypoint": "scheduled", "pre_state": "scheduled_run", "steps": ["execute", "project_history"], "requirements": ["AGENT-ENTRY-SCHEDULED"], "refresh_action": "history_read", "legacy_expectation": {"source": "legacy_fixture"}, "contract_v1_expectation": {"terminal": "completed"}, "planes": {"provider": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "domain": {"legacy": "required", "contract_v1": "required"}, "sse": {"legacy": "not_applicable", "contract_v1": "not_applicable"}, "db_events": {"legacy": "required", "contract_v1": "required"}, "conversation": {"legacy": "required", "contract_v1": "required"}}} - ]} - ] -} diff --git a/contracts/agent/v1/scenario-vocabulary.json b/contracts/agent/v1/scenario-vocabulary.json deleted file mode 100644 index 8837a326..00000000 --- a/contracts/agent/v1/scenario-vocabulary.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "schema_version": "1.0", - "pre_states": [ - {"id": "new_session", "meaning": "No persisted Session exists before the turn."}, - {"id": "new_sop_session", "meaning": "A new Session can start the selected SOP."}, - {"id": "condition_step", "meaning": "The active graph node has a deterministic conditional branch."}, - {"id": "parallel_start", "meaning": "The active graph node can enqueue sibling nodes."}, - {"id": "active_plus_pending", "meaning": "One legacy task is active and at least one task is pending."}, - {"id": "multiple_turn_frames", "meaning": "Multiple legacy task frames must be processed in order."}, - {"id": "repairable_result", "meaning": "Reflection can repair the current result within budget."}, - {"id": "unrepairable_result", "meaning": "Reflection reaches its retry limit."}, - {"id": "knowledge_available", "meaning": "Local knowledge contains stable citation evidence."}, - {"id": "required_knowledge_step", "meaning": "The active SOP step requires knowledge before advance."}, - {"id": "tool_step", "meaning": "The active step allows the selected read-only tool."}, - {"id": "existing_successful_effect", "meaning": "A successful side-effect signature already exists."}, - {"id": "effect_status_unknown", "meaning": "A side-effect was started but its result is unknown."}, - {"id": "no_active_sop", "meaning": "No scene SOP is active."}, - {"id": "active_sop", "meaning": "A scene SOP is active and can invoke a General Skill."}, - {"id": "running_stream", "meaning": "A chat stream worker is executing a turn."}, - {"id": "configured_model", "meaning": "A deterministic model stub is configured."}, - {"id": "declared_handoff_step", "meaning": "The active step explicitly permits human handoff."}, - {"id": "pending_handoff", "meaning": "A persisted human handoff request is pending."}, - {"id": "draft_detected", "meaning": "Scheduled-task intent detection returns a draft."}, - {"id": "attachment_uploaded", "meaning": "A stable uploaded attachment is available to the turn."}, - {"id": "bound_channel", "meaning": "A deterministic channel binding and outbox exist."}, - {"id": "scheduled_run", "meaning": "A persisted scheduled task run is ready to execute."}, - {"id": "persisted_assistant_message", "meaning": "A committed assistant Message is visible through History and can receive feedback."} - ], - "steps": [ - {"id": "send", "meaning": "Submit the scenario turn request."}, - {"id": "relay", "meaning": "Consume the current legacy SSE relay."}, - {"id": "ask", "meaning": "Produce a user-visible question."}, - {"id": "history_read", "meaning": "Reload the public History projection."}, - {"id": "continue", "meaning": "Submit the next user answer."}, - {"id": "select_true_branch", "meaning": "Select the true conditional edge."}, - {"id": "select_false_branch", "meaning": "Select the false conditional edge."}, - {"id": "first_sibling", "meaning": "Execute the first parallel sibling."}, - {"id": "second_sibling", "meaning": "Execute the second parallel sibling."}, - {"id": "merge", "meaning": "Merge sibling results."}, - {"id": "complete_active", "meaning": "Complete the active legacy task."}, - {"id": "resume_pending", "meaning": "Resume the next pending legacy task."}, - {"id": "execute_frames_in_order", "meaning": "Execute all characterized frames in order."}, - {"id": "reflect", "meaning": "Run one reflection decision."}, - {"id": "retry", "meaning": "Retry the selected repair target."}, - {"id": "complete", "meaning": "Complete the scenario turn."}, - {"id": "reflect_until_limit", "meaning": "Run reflection until the configured limit."}, - {"id": "search", "meaning": "Run the local knowledge query."}, - {"id": "stream_citation", "meaning": "Observe citation-related legacy stream and message output."}, - {"id": "normalize_query", "meaning": "Normalize the required knowledge query."}, - {"id": "advance", "meaning": "Advance the active graph step."}, - {"id": "execute_read", "meaning": "Execute a read-only tool."}, - {"id": "request_same_effect", "meaning": "Request an existing side-effect signature."}, - {"id": "reuse", "meaning": "Reuse the existing effect result."}, - {"id": "start", "meaning": "Start an external action."}, - {"id": "lose_response", "meaning": "Simulate a lost action response."}, - {"id": "reconcile", "meaning": "Reconcile the unknown action status."}, - {"id": "select_skill", "meaning": "Select a General Skill."}, - {"id": "execute", "meaning": "Execute the selected capability or scheduled run."}, - {"id": "reply", "meaning": "Produce the user-visible reply."}, - {"id": "continue_sop", "meaning": "Return General Skill facts to the active SOP."}, - {"id": "disconnect_transport", "meaning": "Detach the client transport without a cancel command."}, - {"id": "reconnect_relay_after_cursor", "meaning": "Reconnect the target v1 relay after a durable cursor."}, - {"id": "await_terminal", "meaning": "Wait for the existing execution terminal state."}, - {"id": "explicit_cancel", "meaning": "Call the explicit cancellation command."}, - {"id": "raise_llm_error", "meaning": "Raise a deterministic model error."}, - {"id": "create_handoff", "meaning": "Create a pending handoff request."}, - {"id": "reply_api", "meaning": "Submit a human handoff reply through the public API."}, - {"id": "resume_worker", "meaning": "Run the legacy handoff resume worker."}, - {"id": "events_poll", "meaning": "Poll the current Session Events recovery API."}, - {"id": "stream_draft", "meaning": "Observe the legacy scheduled draft SSE event."}, - {"id": "terminal_projection", "meaning": "Observe the terminal conversation payload."}, - {"id": "confirm_draft_api", "meaning": "Confirm a scheduled draft through the current public API."}, - {"id": "send_attachment", "meaning": "Submit the stable attachment in a chat turn."}, - {"id": "ingress", "meaning": "Process deterministic channel ingress."}, - {"id": "outbox", "meaning": "Read the staged channel outbox projection."}, - {"id": "project_history", "meaning": "Project a scheduled run into chat History."}, - {"id": "feedback_up_api", "meaning": "Persist positive feedback for the assistant Message."}, - {"id": "feedback_clear_api", "meaning": "Delete persisted feedback for the assistant Message."}, - {"id": "feedback_failure_rollback", "meaning": "Characterize UI rollback when a feedback command fails."} - ], - "refresh_actions": [ - {"id": "history_read", "meaning": "Reload messages and Session state."}, - {"id": "continue_after_history", "meaning": "Continue a waiting SOP after History reload."}, - {"id": "open_citation_after_history", "meaning": "Open the same citation after History reload."}, - {"id": "reconnect_then_history", "meaning": "Reconnect execution delivery and verify History visibility."}, - {"id": "open_handoff_after_history", "meaning": "Open the persisted handoff after reload."}, - {"id": "reply_then_poll_history", "meaning": "Reply to handoff, poll events, and reload History."}, - {"id": "confirm_after_history", "meaning": "Confirm the restored scheduled draft."}, - {"id": "inspect_attachment_after_history", "meaning": "Inspect restored attachment metadata."}, - {"id": "feedback_after_history", "meaning": "Toggle persisted assistant feedback after History reload."} - ] -} diff --git a/contracts/agent/v1/schemas/compatibility-matrix.schema.json b/contracts/agent/v1/schemas/compatibility-matrix.schema.json deleted file mode 100644 index b9f4d490..00000000 --- a/contracts/agent/v1/schemas/compatibility-matrix.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "compatibility-matrix.schema.json", - "title": "Agent Compatibility Matrix v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "surfaces", "read_cases", "frame_assignment_cases", "rollback_cases"], - "properties": { - "schema_version": {"const": "1.0"}, - "surfaces": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/surface"}}, - "read_cases": {"type": "array", "minItems": 5, "items": {"$ref": "#/$defs/case"}}, - "frame_assignment_cases": {"type": "array", "minItems": 7, "items": {"$ref": "#/$defs/case"}}, - "rollback_cases": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/case"}} - }, - "$defs": { - "surface": { - "type": "object", - "additionalProperties": false, - "required": ["surface", "legacy", "v1"], - "properties": { - "surface": {"type": "string", "minLength": 1}, - "legacy": {"type": "string", "minLength": 1}, - "v1": {"type": "string", "minLength": 1} - } - }, - "case": { - "type": "object", - "minProperties": 2, - "propertyNames": {"pattern": "^[a-z][a-z0-9_]*$"}, - "additionalProperties": {"type": ["string", "boolean", "number", "null", "array"]} - } - } -} diff --git a/contracts/agent/v1/schemas/conformance-report.schema.json b/contracts/agent/v1/schemas/conformance-report.schema.json deleted file mode 100644 index 742f3aa1..00000000 --- a/contracts/agent/v1/schemas/conformance-report.schema.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "conformance-report.schema.json", - "title": "Agent Contract Conformance Report v1", - "type": "object", - "additionalProperties": false, - "required": [ - "report_version", "contract_version", "phase_scope", "source_revision", "generated_at", "environment", - "gate_decision", "gate_reasons", "runtime_conformance", "legacy_characterization_status", - "schema_validation_results", "scenario_plane_results", "requirement_coverage", "vocabulary_coverage", - "join_invariant_results", "happens_before_results", "pairwise_coverage", "seam_coverage", - "corpus_coverage", "determinism_runs", "http_sse_harness_results", "frontend_baseline_results", - "known_legacy_defects", "missing_assets", "artifact_hashes", "reviews" - ], - "properties": { - "report_version": {"const": "1.0"}, - "contract_version": {"const": "agent/v1"}, - "phase_scope": {"const": "0A"}, - "source_revision": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, - "generated_at": {"type": "string", "format": "date-time"}, - "environment": {"type": "object"}, - "gate_decision": {"enum": ["go", "no_go"]}, - "gate_reasons": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "runtime_conformance": {"enum": ["not_implemented", "conformant", "nonconformant"]}, - "legacy_characterization_status": {"enum": ["not_started", "in_progress", "complete"]}, - "schema_validation_results": {"$ref": "#/$defs/checks"}, - "scenario_plane_results": {"$ref": "#/$defs/checks"}, - "requirement_coverage": {"$ref": "#/$defs/coverage"}, - "vocabulary_coverage": {"$ref": "#/$defs/coverage"}, - "join_invariant_results": {"$ref": "#/$defs/checks"}, - "happens_before_results": {"$ref": "#/$defs/checks"}, - "pairwise_coverage": {"$ref": "#/$defs/coverage"}, - "seam_coverage": {"$ref": "#/$defs/coverage"}, - "corpus_coverage": {"$ref": "#/$defs/coverage"}, - "determinism_runs": { - "type": "array", - "minItems": 2, - "items": {"$ref": "#/$defs/determinismRun"} - }, - "http_sse_harness_results": {"$ref": "#/$defs/checks"}, - "frontend_baseline_results": {"$ref": "#/$defs/checks"}, - "known_legacy_defects": {"type": "array", "items": {"type": "object"}}, - "missing_assets": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "artifact_hashes": {"type": "object", "additionalProperties": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}}, - "reviews": {"type": "array", "items": {"type": "object"}} - }, - "$defs": { - "checks": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "status", "evidence_mode", "gate_required", "detail"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "status": {"enum": ["pass", "fail", "not_run", "future_phase"]}, - "evidence_mode": {"enum": ["automated", "manual_observation", "not_available"]}, - "gate_required": {"type": "boolean"}, - "detail": {"type": "string"} - } - } - }, - "coverage": { - "type": "object", - "additionalProperties": false, - "required": ["status", "covered", "required", "missing"], - "properties": { - "status": {"enum": ["complete", "incomplete", "future_phase"]}, - "covered": {"type": "integer", "minimum": 0}, - "required": {"type": "integer", "minimum": 0}, - "missing": {"type": "array", "items": {"type": "string"}} - } - }, - "determinismRun": { - "type": "object", - "additionalProperties": false, - "required": ["scenario", "run", "execution", "hash_seed", "timezone", "canonical_match"], - "properties": { - "scenario": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "run": {"type": "integer", "minimum": 1}, - "execution": {"enum": ["in_process", "fresh_process"]}, - "hash_seed": {"type": ["string", "null"]}, - "timezone": {"type": "string", "minLength": 1}, - "canonical_match": {"type": "boolean"} - } - } - } -} diff --git a/contracts/agent/v1/schemas/conversation-projection.schema.json b/contracts/agent/v1/schemas/conversation-projection.schema.json deleted file mode 100644 index eb681cc2..00000000 --- a/contracts/agent/v1/schemas/conversation-projection.schema.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "conversation-projection.schema.json", - "title": "Committed Conversation Projection v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "turn_id", "run_id", "assistant_message"], - "properties": { - "schema_version": {"const": "1"}, - "turn_id": {"type": "string", "minLength": 1}, - "run_id": {"type": ["string", "null"]}, - "assistant_message": { - "type": "object", - "additionalProperties": false, - "required": ["message_id", "role", "content", "interaction_blocks", "allowed_actions"], - "properties": { - "message_id": {"type": "string", "minLength": 1}, - "role": {"const": "assistant"}, - "content": {"type": "string"}, - "interaction_blocks": {"type": "array", "items": {"$ref": "interaction-block.schema.json"}}, - "allowed_actions": {"type": "array", "uniqueItems": true, "items": {"enum": ["feedback_up", "feedback_down"]}} - } - } - } -} diff --git a/contracts/agent/v1/schemas/field-ownership.schema.json b/contracts/agent/v1/schemas/field-ownership.schema.json deleted file mode 100644 index 765c863f..00000000 --- a/contracts/agent/v1/schemas/field-ownership.schema.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "field-ownership.schema.json", - "title": "Agent Plane Field Ownership v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "planes", "join_rules", "happens_before_rules", "legacy_field_observation"], - "properties": { - "schema_version": {"const": "1.0"}, - "planes": { - "type": "object", - "additionalProperties": false, - "required": ["provider", "domain", "sse", "db_events", "conversation"], - "properties": { - "provider": {"$ref": "#/$defs/ownership"}, - "domain": {"$ref": "#/$defs/ownership"}, - "sse": {"$ref": "#/$defs/ownership"}, - "db_events": {"$ref": "#/$defs/ownership"}, - "conversation": {"$ref": "#/$defs/ownership"} - } - }, - "join_rules": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/joinRule"} - }, - "happens_before_rules": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/orderRule"} - }, - "legacy_field_observation": { - "type": "object", - "additionalProperties": false, - "required": ["expectations"], - "properties": { - "expectations": { - "type": "object", - "minProperties": 1, - "additionalProperties": {"$ref": "#/$defs/fieldExpectation"} - } - } - } - }, - "$defs": { - "ownership": { - "type": "object", - "additionalProperties": false, - "required": ["owns", "forbidden"], - "properties": { - "owns": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/ownedClaim"} - }, - "forbidden": { - "type": "array", - "uniqueItems": true, - "items": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"} - } - } - }, - "ownedClaim": { - "type": "object", - "additionalProperties": false, - "required": ["claim", "payload_paths"], - "properties": { - "claim": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, - "payload_paths": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/pointerPattern"} - } - } - }, - "pointerPattern": { - "type": "string", - "pattern": "^(?:/(?:\\*|[^~/]|~[01])*)*$" - }, - "fieldExpectation": { - "type": "object", - "additionalProperties": false, - "required": ["default", "overrides"], - "properties": { - "default": {"enum": ["present", "absent"]}, - "overrides": { - "type": "object", - "additionalProperties": false, - "properties": { - "provider": {"enum": ["present", "absent"]}, - "domain": {"enum": ["present", "absent"]}, - "sse": {"enum": ["present", "absent"]}, - "db_events": {"enum": ["present", "absent"]}, - "conversation": {"enum": ["present", "absent"]} - } - } - } - }, - "fixtureSets": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"enum": ["legacy_characterization", "contract_v1"]} - }, - "pointerScope": { - "type": "object", - "additionalProperties": false, - "required": ["plane", "pointer_prefix"], - "properties": { - "plane": {"enum": ["provider", "domain", "sse", "db_events", "conversation"]}, - "pointer_prefix": {"type": "string", "pattern": "^(?:/(?:[^~/]|~[01])*)*$"} - } - }, - "joinRule": { - "type": "object", - "additionalProperties": false, - "required": ["id", "fixture_sets", "operator", "minimum_references", "allowed_references"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "fixture_sets": {"$ref": "#/$defs/fixtureSets"}, - "operator": {"const": "equal"}, - "minimum_references": {"type": "integer", "minimum": 2}, - "allowed_references": { - "type": "array", - "minItems": 2, - "items": {"$ref": "#/$defs/pointerScope"} - } - } - }, - "orderRule": { - "type": "object", - "additionalProperties": false, - "required": ["id", "fixture_sets", "order_types", "before_references", "after_references"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "fixture_sets": {"$ref": "#/$defs/fixtureSets"}, - "order_types": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"enum": ["integer", "number", "rfc3339"]} - }, - "before_references": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/pointerScope"} - }, - "after_references": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/pointerScope"} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/fixture-envelope.schema.json b/contracts/agent/v1/schemas/fixture-envelope.schema.json deleted file mode 100644 index 32bf3992..00000000 --- a/contracts/agent/v1/schemas/fixture-envelope.schema.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "fixture-envelope.schema.json", - "title": "Agent Golden Plane Fixture Envelope v1", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", "fixture_set", "scenario_id", "variant_id", "plane", "applicability", - "payload_schema", "normalization_profile", "source", "legacy_field_observation", - "content_hash", "joins", "happens_before" - ], - "properties": { - "schema_version": {"const": "1.0"}, - "fixture_set": {"enum": ["legacy_characterization", "contract_v1"]}, - "scenario_id": {"type": "string", "pattern": "^GT[0-9]{2}$"}, - "variant_id": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "plane": {"enum": ["provider", "domain", "sse", "db_events", "conversation"]}, - "applicability": {"enum": ["required", "not_applicable", "deferred"]}, - "payload_schema": {"type": ["string", "null"]}, - "normalization_profile": {"const": "agent-golden-v1"}, - "source": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "repo_revision", "artifacts", "capture_harness"], - "properties": { - "kind": {"enum": ["captured_runtime", "target_contract", "explicit_manifest"]}, - "repo_revision": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, - "artifacts": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["path", "sha256"], - "properties": { - "path": {"type": "string", "minLength": 1}, - "sha256": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"} - } - } - }, - "capture_harness": {"type": ["string", "null"]} - } - }, - "legacy_field_observation": { - "type": "object", - "additionalProperties": {"enum": ["present", "absent"]} - }, - "payload": {"not": {"type": "null"}}, - "omission": { - "type": "object", - "additionalProperties": false, - "required": ["reason"], - "properties": {"reason": {"type": "string", "minLength": 1}} - }, - "content_hash": {"type": ["string", "null"], "pattern": "^(sha256:[a-f0-9]{64})?$"}, - "joins": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "rule_id", "references"], - "properties": { - "name": {"type": "string", "minLength": 1}, - "rule_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "references": { - "type": "array", - "minItems": 2, - "uniqueItems": true, - "items": {"$ref": "#/$defs/planePointer"} - } - } - } - }, - "happens_before": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "rule_id", "order_type", "before", "after"], - "properties": { - "name": {"type": "string", "minLength": 1}, - "rule_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "order_type": {"enum": ["integer", "number", "rfc3339"]}, - "before": {"$ref": "#/$defs/planePointer"}, - "after": {"$ref": "#/$defs/planePointer"} - } - } - }, - "notes": {"type": "array", "items": {"type": "string"}} - }, - "$defs": { - "planePointer": { - "type": "object", - "additionalProperties": false, - "required": ["role", "plane", "pointer"], - "properties": { - "role": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, - "plane": {"enum": ["provider", "domain", "sse", "db_events", "conversation"]}, - "pointer": { - "type": "string", - "pattern": "^(?:/(?:[^~/]|~[01])*)*$" - } - } - } - }, - "allOf": [ - { - "if": {"properties": {"applicability": {"const": "required"}}, "required": ["applicability"]}, - "then": {"required": ["payload"], "not": {"required": ["omission"]}, "properties": {"payload_schema": {"type": "string", "minLength": 1}, "content_hash": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}}} - }, - { - "if": {"properties": {"applicability": {"enum": ["not_applicable", "deferred"]}}, "required": ["applicability"]}, - "then": {"required": ["omission"], "not": {"required": ["payload"]}, "properties": {"payload_schema": {"type": "null"}, "content_hash": {"type": "null"}, "source": {"properties": {"kind": {"const": "explicit_manifest"}}}}} - } - ] -} diff --git a/contracts/agent/v1/schemas/graph-characterization.schema.json b/contracts/agent/v1/schemas/graph-characterization.schema.json deleted file mode 100644 index 3b3e0c9b..00000000 --- a/contracts/agent/v1/schemas/graph-characterization.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "graph-characterization.schema.json", - "title": "Legacy Graph Characterization Case v1", - "type": "object", - "additionalProperties": false, - "required": ["case_id", "skill_fixture", "pre_state", "operation", "expected"], - "properties": { - "case_id": {"type": "string", "minLength": 1}, - "skill_fixture": {"type": "string", "minLength": 1}, - "pre_state": {"type": "object"}, - "operation": {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}, - "expected": { - "type": "object", - "required": ["facts", "effects", "diagnostics"], - "properties": { - "facts": {"type": "object"}, - "effects": {"type": "array", "items": {"type": "object"}}, - "diagnostics": {"type": "array", "items": {"type": "object"}} - } - } - } -} diff --git a/contracts/agent/v1/schemas/interaction-block.schema.json b/contracts/agent/v1/schemas/interaction-block.schema.json deleted file mode 100644 index f699e77e..00000000 --- a/contracts/agent/v1/schemas/interaction-block.schema.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "interaction-block.schema.json", - "title": "Conversation Interaction Block v1", - "oneOf": [ - {"$ref": "#/$defs/citation"}, - {"$ref": "#/$defs/scheduledDraft"}, - {"$ref": "#/$defs/artifact"} - ], - "$defs": { - "extensions": { - "type": "object", - "propertyNames": {"pattern": "^[a-z][a-z0-9_.-]+$"} - }, - "citation": { - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "interaction_id", "kind", "resource_id", "state", "allowed_actions", "title", "excerpt", "extensions"], - "properties": { - "schema_version": {"const": "1"}, - "interaction_id": {"type": "string", "minLength": 1}, - "kind": {"const": "citation"}, - "resource_id": {"type": "string", "minLength": 1}, - "state": {"enum": ["active", "source_unavailable", "access_revoked", "redacted"]}, - "allowed_actions": { - "type": "array", - "uniqueItems": true, - "items": {"enum": ["view_snapshot", "resolve_full_text"]} - }, - "title": {"type": "string"}, - "excerpt": {"type": "string"}, - "source_label": {"type": ["string", "null"]}, - "extensions": {"$ref": "#/$defs/extensions"} - }, - "allOf": [ - { - "if": {"properties": {"state": {"enum": ["source_unavailable", "redacted"]}}, "required": ["state"]}, - "then": {"properties": {"allowed_actions": {"items": {"const": "view_snapshot"}}}} - }, - { - "if": {"properties": {"state": {"const": "access_revoked"}}, "required": ["state"]}, - "then": {"properties": {"allowed_actions": {"maxItems": 0}}} - } - ] - }, - "scheduledDraft": { - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", "interaction_id", "kind", "resource_id", "state", "allowed_actions", - "draft_id", "source_message_id", "source_turn_id", "idempotency_key", "version", "expires_at", "extensions" - ], - "properties": { - "schema_version": {"const": "1"}, - "interaction_id": {"type": "string", "minLength": 1}, - "kind": {"const": "scheduled_draft"}, - "resource_id": {"type": "string", "minLength": 1}, - "state": {"enum": ["pending", "confirmed", "dismissed", "expired"]}, - "allowed_actions": { - "type": "array", - "uniqueItems": true, - "items": {"enum": ["confirm", "dismiss", "edit"]} - }, - "draft_id": {"type": "string", "minLength": 1}, - "source_message_id": {"type": "string", "minLength": 1}, - "source_turn_id": {"type": "string", "minLength": 1}, - "idempotency_key": {"type": "string", "minLength": 1}, - "version": {"type": "integer", "minimum": 1}, - "expires_at": {"type": ["string", "null"], "format": "date-time"}, - "extensions": {"$ref": "#/$defs/extensions"} - }, - "allOf": [ - { - "if": {"properties": {"state": {"const": "pending"}}, "required": ["state"]}, - "then": {"properties": {"allowed_actions": {"minItems": 1}}}, - "else": {"properties": {"allowed_actions": {"maxItems": 0}}} - } - ] - }, - "artifact": { - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "interaction_id", "kind", "resource_id", "state", "allowed_actions", "artifact_id", "label", "extensions"], - "properties": { - "schema_version": {"const": "1"}, - "interaction_id": {"type": "string", "minLength": 1}, - "kind": {"const": "artifact"}, - "resource_id": {"type": "string", "minLength": 1}, - "state": {"enum": ["active", "expired", "removed"]}, - "allowed_actions": {"type": "array", "uniqueItems": true, "items": {"enum": ["preview", "download"]}}, - "artifact_id": {"type": "string", "minLength": 1}, - "label": {"type": "string"}, - "extensions": {"$ref": "#/$defs/extensions"} - }, - "allOf": [ - { - "if": {"properties": {"state": {"const": "active"}}, "required": ["state"]}, - "else": {"properties": {"allowed_actions": {"maxItems": 0}}} - } - ] - } - } -} diff --git a/contracts/agent/v1/schemas/legacy-seams.schema.json b/contracts/agent/v1/schemas/legacy-seams.schema.json deleted file mode 100644 index eff5ce68..00000000 --- a/contracts/agent/v1/schemas/legacy-seams.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "legacy-seams.schema.json", - "title": "Agent Legacy Seam Inventory v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "class", "constructor_bypasses", "graph_methods", "call_topologies_to_characterize"], - "properties": { - "schema_version": {"const": "1.0"}, - "class": {"const": "app.core.agent_loop.AgentLoop"}, - "constructor_bypasses": {"$ref": "#/$defs/names"}, - "graph_methods": {"$ref": "#/$defs/names"}, - "call_topologies_to_characterize": { - "type": "array", - "minItems": 1, - "items": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1}} - } - }, - "$defs": { - "names": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}} - } -} diff --git a/contracts/agent/v1/schemas/legacy-skill-corpus.schema.json b/contracts/agent/v1/schemas/legacy-skill-corpus.schema.json deleted file mode 100644 index 042441cb..00000000 --- a/contracts/agent/v1/schemas/legacy-skill-corpus.schema.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "legacy-skill-corpus.schema.json", - "title": "Legacy Skill Corpus Manifest v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "corpus_class", "source_revision", "entries"], - "properties": { - "schema_version": {"const": "1.0"}, - "corpus_class": {"enum": ["test_reference", "production_seed"]}, - "source_revision": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, - "entries": { - "type": "array", - "minItems": 3, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["skill_id", "version", "source_path", "source_symbol", "fixture_path", "content_hash"], - "properties": { - "skill_id": {"type": "string", "minLength": 1}, - "version": {"type": "string", "minLength": 1}, - "source_path": {"type": "string", "minLength": 1}, - "source_symbol": {"type": "string", "minLength": 1}, - "fixture_path": {"type": "string", "minLength": 1}, - "content_hash": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/manifest.schema.json b/contracts/agent/v1/schemas/manifest.schema.json deleted file mode 100644 index 6d2f6cec..00000000 --- a/contracts/agent/v1/schemas/manifest.schema.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "manifest.schema.json", - "title": "Agent Contract Asset Manifest v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "contract_version", "status", "schemas", "instances", "fixture_roots", "deferred_provider_contracts"], - "properties": { - "schema_version": {"const": "1.0"}, - "contract_version": {"const": "agent/v1"}, - "status": {"enum": ["phase_0_in_progress", "phase_0_complete"]}, - "schemas": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "path", "role"], - "properties": { - "id": {"type": "string", "minLength": 1}, - "path": {"type": "string", "pattern": "^schemas/.+\\.json$"}, - "role": {"enum": ["asset", "fixture_envelope", "fixture_payload", "target_conversation"]} - } - } - }, - "instances": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["path", "schema"], - "properties": { - "path": {"type": "string", "minLength": 1}, - "schema": {"type": "string", "minLength": 1} - } - } - }, - "fixture_roots": { - "type": "object", - "additionalProperties": false, - "required": ["legacy_characterization", "contract_v1"], - "properties": { - "legacy_characterization": {"type": "string"}, - "contract_v1": {"type": "string"} - } - }, - "deferred_provider_contracts": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["service", "required_from_phase", "reason"], - "properties": { - "service": {"enum": ["knowledge", "scene_skill", "general_skill"]}, - "required_from_phase": {"const": "provider_contract_slice"}, - "reason": {"type": "string", "minLength": 1} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/normalization-profiles.schema.json b/contracts/agent/v1/schemas/normalization-profiles.schema.json deleted file mode 100644 index ddcdd068..00000000 --- a/contracts/agent/v1/schemas/normalization-profiles.schema.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "normalization-profiles.schema.json", - "title": "Golden Fixture Normalization Profiles v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "profiles"], - "properties": { - "schema_version": {"const": "1.0"}, - "profiles": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "sort_object_keys", "preserve_array_order", "preserve_nulls", "preserve_duplicates", "rules"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z0-9_.-]+$"}, - "sort_object_keys": {"const": true}, - "preserve_array_order": {"const": true}, - "preserve_nulls": {"const": true}, - "preserve_duplicates": {"const": true}, - "rules": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["match", "strategy"], - "properties": { - "match": {"type": "string", "minLength": 1}, - "strategy": {"enum": ["identity_map", "monotonic_time_map", "duration_placeholder", "traceback_normalized", "preserve"]}, - "qualifier": { - "type": "object", - "additionalProperties": false, - "required": ["levels_up", "relative_pointer", "equals"], - "properties": { - "levels_up": {"type": "integer", "minimum": 1}, - "relative_pointer": {"type": "string", "pattern": "^/"}, - "equals": {} - } - } - } - } - } - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/pairwise-manifest.schema.json b/contracts/agent/v1/schemas/pairwise-manifest.schema.json deleted file mode 100644 index 914ac277..00000000 --- a/contracts/agent/v1/schemas/pairwise-manifest.schema.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "pairwise-manifest.schema.json", - "title": "Agent Pairwise Coverage Manifest v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "generator", "status", "common_dimensions", "coverage_profiles", "notes"], - "properties": { - "schema_version": {"const": "1.0"}, - "generator": {"$ref": "#/$defs/generator"}, - "status": {"enum": ["incomplete", "complete"]}, - "common_dimensions": { - "type": "object", - "additionalProperties": false, - "required": ["transport", "session", "outcome", "scenario", "action"], - "properties": { - "transport": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["sync", "sse"]}}, - "session": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["new", "existing", "refresh"]}}, - "outcome": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["success", "partial", "failure", "timeout", "cancel"]}}, - "scenario": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["none", "single_sop", "pending", "multi_frame"]}}, - "action": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["none", "knowledge", "read_tool", "side_effect_tool", "general_skill"]}} - } - }, - "coverage_profiles": { - "type": "object", - "additionalProperties": false, - "required": ["0A_legacy", "provider_contract"], - "properties": { - "0A_legacy": {"$ref": "#/$defs/profile"}, - "provider_contract": {"$ref": "#/$defs/profile"} - } - }, - "notes": {"type": "array", "items": {"type": "string", "minLength": 1}} - }, - "$defs": { - "generator": { - "type": "object", - "additionalProperties": false, - "required": ["name", "version", "seed", "tie_breaker", "dimension_order"], - "properties": { - "name": {"const": "staffdeck_deterministic_greedy_pairwise"}, - "version": {"const": "1.0.0"}, - "seed": {"const": 0}, - "tie_breaker": {"const": "lexicographic_assignment"}, - "dimension_order": { - "const": ["transport", "session", "outcome", "scenario", "action", "provider_boundary"] - } - } - }, - "profile": { - "type": "object", - "additionalProperties": false, - "required": ["status", "required_from_phase", "provider_boundary", "constraints", "coverage", "cases", "missing_pairs_are_gate_failures"], - "properties": { - "status": {"enum": ["incomplete", "complete", "deferred"]}, - "required_from_phase": {"enum": ["0A", "provider_contract_slice"]}, - "provider_boundary": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, - "constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}}, - "coverage": {"oneOf": [{"$ref": "#/$defs/coverage"}, {"type": "null"}]}, - "cases": {"type": "array", "items": {"$ref": "#/$defs/case"}}, - "missing_pairs_are_gate_failures": {"const": true} - }, - "allOf": [ - { - "if": {"properties": {"status": {"const": "complete"}}, "required": ["status"]}, - "then": { - "properties": { - "constraints": {"minItems": 1}, - "coverage": {"$ref": "#/$defs/coverage"}, - "cases": {"minItems": 1} - } - } - }, - { - "if": {"properties": {"status": {"const": "deferred"}}, "required": ["status"]}, - "then": { - "properties": { - "constraints": {"maxItems": 0}, - "coverage": {"type": "null"}, - "cases": {"maxItems": 0} - } - } - } - ] - }, - "constraint": { - "type": "object", - "additionalProperties": false, - "required": ["id", "variant_id", "entrypoint", "allowed"], - "properties": { - "id": {"type": "string", "pattern": "^LC[0-9]{2}-[a-z0-9-]+$"}, - "variant_id": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "entrypoint": {"enum": ["chat_sync", "chat_sse"]}, - "allowed": {"$ref": "#/$defs/values"} - } - }, - "coverage": { - "type": "object", - "additionalProperties": false, - "required": ["strength", "legal_assignment_count", "legal_pair_count", "covered_pair_count", "coverage_digest", "require_each_constraint"], - "properties": { - "strength": {"const": 2}, - "legal_assignment_count": {"type": "integer", "minimum": 0}, - "legal_pair_count": {"type": "integer", "minimum": 0}, - "covered_pair_count": {"type": "integer", "minimum": 0}, - "coverage_digest": {"type": "string", "pattern": "^sha256:(?:[a-f0-9]{64}|pending)$"}, - "require_each_constraint": {"const": true} - } - }, - "case": { - "type": "object", - "additionalProperties": false, - "required": ["case_id", "constraint_id", "variant_id", "entrypoint", "values"], - "properties": { - "case_id": {"type": "string", "pattern": "^PW0A-[0-9]{3}$"}, - "constraint_id": {"type": "string", "pattern": "^LC[0-9]{2}-[a-z0-9-]+$"}, - "variant_id": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "entrypoint": {"enum": ["chat_sync", "chat_sse"]}, - "values": {"$ref": "#/$defs/scalar_values"} - } - }, - "values": { - "type": "object", - "additionalProperties": false, - "required": ["transport", "session", "outcome", "scenario", "action", "provider_boundary"], - "properties": { - "transport": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["sync", "sse"]}}, - "session": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["new", "existing", "refresh"]}}, - "outcome": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["success", "partial", "failure", "timeout", "cancel"]}}, - "scenario": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["none", "single_sop", "pending", "multi_frame"]}}, - "action": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["none", "knowledge", "read_tool", "side_effect_tool", "general_skill"]}}, - "provider_boundary": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}} - } - }, - "scalar_values": { - "type": "object", - "additionalProperties": false, - "required": ["transport", "session", "outcome", "scenario", "action", "provider_boundary"], - "properties": { - "transport": {"enum": ["sync", "sse"]}, - "session": {"enum": ["new", "existing", "refresh"]}, - "outcome": {"enum": ["success", "partial", "failure", "timeout", "cancel"]}, - "scenario": {"enum": ["none", "single_sop", "pending", "multi_frame"]}, - "action": {"enum": ["none", "knowledge", "read_tool", "side_effect_tool", "general_skill"]}, - "provider_boundary": {"type": "string", "minLength": 1} - } - } - } -} diff --git a/contracts/agent/v1/schemas/planes/conversation.schema.json b/contracts/agent/v1/schemas/planes/conversation.schema.json deleted file mode 100644 index 04cb412d..00000000 --- a/contracts/agent/v1/schemas/planes/conversation.schema.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "planes/conversation.schema.json", - "title": "Conversation and History Golden Payload v1", - "type": "object", - "additionalProperties": false, - "required": ["sync_response", "messages", "session", "persisted_pre_state", "persisted_session", "interaction_checks"], - "properties": { - "sync_response": {"type": ["object", "null"]}, - "messages": {"type": "array", "minItems": 1, "items": {"type": "object"}}, - "session": {"type": ["object", "null"]}, - "persisted_pre_state": {"type": ["object", "null"]}, - "persisted_session": {"type": "object"}, - "interaction_checks": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "realtime_observation", "refresh_observation", "action_result"], - "properties": { - "kind": {"enum": ["citation", "scheduled_draft", "handoff", "feedback", "attachment", "none"]}, - "realtime_observation": {"enum": ["not_observed", "transport_event_observed", "frontend_visible"]}, - "refresh_observation": {"enum": ["not_observed", "history_payload_observed", "frontend_visible"]}, - "action_result": {"oneOf": [{"$ref": "#/$defs/actionEvidence"}, {"type": "null"}]} - }, - "allOf": [ - { - "if": {"properties": {"kind": {"const": "none"}}, "required": ["kind"]}, - "then": {"properties": {"action_result": {"type": "null"}}}, - "else": {"properties": {"action_result": {"$ref": "#/$defs/actionEvidence"}}} - } - ] - } - }, - "outbox": {"type": "array", "items": {"type": "object"}} - }, - "$defs": { - "actionEvidence": { - "type": "object", - "additionalProperties": false, - "required": ["evidence_id", "evidence_origin", "resource_id", "action", "request", "response_status", "persisted_state"], - "properties": { - "evidence_id": {"type": "string", "minLength": 1}, - "evidence_origin": {"const": "harness_synthetic"}, - "resource_id": {"type": "string", "minLength": 1}, - "action": {"type": "string", "minLength": 1}, - "request": {"type": "object"}, - "response_status": {"type": "integer", "minimum": 100, "maximum": 599}, - "persisted_state": {"type": "object"} - } - } - } -} diff --git a/contracts/agent/v1/schemas/planes/db-events.schema.json b/contracts/agent/v1/schemas/planes/db-events.schema.json deleted file mode 100644 index 96368b28..00000000 --- a/contracts/agent/v1/schemas/planes/db-events.schema.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "planes/db-events.schema.json", - "title": "Committed Agent DB Event Golden Payload v1", - "type": "object", - "additionalProperties": false, - "required": ["events"], - "properties": { - "events": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["observed_row_order", "event_id", "event_type", "tenant_id", "session_id", "created_at", "payload"], - "properties": { - "observed_row_order": {"type": "integer", "minimum": 0}, - "event_id": {"type": "string", "minLength": 1}, - "event_type": {"type": "string", "minLength": 1}, - "tenant_id": {"type": "string", "minLength": 1}, - "session_id": {"type": "string", "minLength": 1}, - "created_at": {"type": "string", "format": "date-time"}, - "payload": {"type": "object"} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/planes/domain.schema.json b/contracts/agent/v1/schemas/planes/domain.schema.json deleted file mode 100644 index e80c9ad4..00000000 --- a/contracts/agent/v1/schemas/planes/domain.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "planes/domain.schema.json", - "title": "Agent Domain Golden Payload v1", - "type": "object", - "additionalProperties": false, - "required": ["request", "router_decision", "step_result", "tool_result", "outcome", "facts"], - "properties": { - "request": {"type": "object", "minProperties": 1}, - "router_decision": {"type": ["object", "null"]}, - "step_result": {"type": ["object", "null"]}, - "tool_result": {"type": ["object", "null"]}, - "outcome": {"type": "object", "minProperties": 1}, - "facts": {"type": "array", "items": {"type": "object"}}, - "termination": {"type": ["string", "null"]} - } -} diff --git a/contracts/agent/v1/schemas/planes/legacy-provider-exchange.schema.json b/contracts/agent/v1/schemas/planes/legacy-provider-exchange.schema.json deleted file mode 100644 index 1b9c4c85..00000000 --- a/contracts/agent/v1/schemas/planes/legacy-provider-exchange.schema.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "planes/legacy-provider-exchange.schema.json", - "title": "Legacy Local Service Exchange Capture v1", - "type": "object", - "additionalProperties": false, - "required": ["exchanges"], - "properties": { - "exchanges": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["sequence", "service", "boundary_id", "source_symbol", "operation", "request", "result", "error"], - "properties": { - "sequence": {"type": "integer", "minimum": 0}, - "service": {"enum": ["knowledge", "tool", "general_skill"]}, - "boundary_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "source_symbol": {"type": "string", "minLength": 1}, - "operation": {"type": "string", "minLength": 1}, - "request": {}, - "result": {}, - "error": {"type": ["object", "null"]} - } - } - } - }, - "description": "This schema captures current Local exchanges without defining a universal Provider response contract." -} diff --git a/contracts/agent/v1/schemas/planes/sse.schema.json b/contracts/agent/v1/schemas/planes/sse.schema.json deleted file mode 100644 index 173ece70..00000000 --- a/contracts/agent/v1/schemas/planes/sse.schema.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "planes/sse.schema.json", - "title": "Chat SSE Golden Payload v1", - "type": "object", - "additionalProperties": false, - "required": ["http", "events"], - "properties": { - "http": { - "type": "object", - "additionalProperties": false, - "required": ["method", "path", "status", "content_type"], - "properties": { - "method": {"const": "POST"}, - "path": {"const": "/api/chat/stream"}, - "status": {"type": "integer"}, - "content_type": {"type": "string", "pattern": "^text/event-stream"} - } - }, - "events": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["sequence", "id", "event", "data"], - "properties": { - "sequence": {"type": "integer", "minimum": 0}, - "id": {"type": ["string", "null"]}, - "event": {"type": "string", "minLength": 1}, - "data": {"type": "object"} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/relationship-requirements.schema.json b/contracts/agent/v1/schemas/relationship-requirements.schema.json deleted file mode 100644 index 53e4f643..00000000 --- a/contracts/agent/v1/schemas/relationship-requirements.schema.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "relationship-requirements.schema.json", - "title": "Required Golden Fixture Relationships v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "reference_profiles", "variants"], - "properties": { - "schema_version": {"const": "1.0"}, - "reference_profiles": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "oneOf": [ - {"$ref": "#/$defs/joinProfile"}, - {"$ref": "#/$defs/orderProfile"} - ] - } - }, - "variants": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["fixture_set", "variant_id", "relations"], - "properties": { - "fixture_set": {"enum": ["legacy_characterization", "contract_v1"]}, - "variant_id": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "relations": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/relationRequirement"} - } - } - } - } - }, - "$defs": { - "plane": {"enum": ["provider", "domain", "sse", "db_events", "conversation"]}, - "endpointPattern": { - "type": "object", - "additionalProperties": false, - "required": ["role", "plane", "pointer_pattern"], - "properties": { - "role": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, - "plane": {"$ref": "#/$defs/plane"}, - "pointer_pattern": {"type": "string", "pattern": "^(?:/(?:\\*|[^~/]|~[01])*)*$"}, - "qualifier": { - "type": "object", - "additionalProperties": false, - "required": ["levels_up", "relative_pointer", "equals"], - "properties": { - "levels_up": {"type": "integer", "minimum": 1}, - "relative_pointer": {"type": "string", "pattern": "^(?:/(?:[^~/]|~[01])*)*$"}, - "equals": {"type": ["string", "number", "integer", "boolean", "null"]} - } - } - } - }, - "joinProfile": { - "type": "object", - "additionalProperties": false, - "required": ["id", "kind", "rule_id", "references"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "kind": {"const": "join"}, - "rule_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "references": { - "type": "array", - "minItems": 2, - "uniqueItems": true, - "items": {"$ref": "#/$defs/endpointPattern"} - } - } - }, - "orderProfile": { - "type": "object", - "additionalProperties": false, - "required": ["id", "kind", "rule_id", "order_type", "before", "after"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "kind": {"const": "happens_before"}, - "rule_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "order_type": {"enum": ["integer", "number", "rfc3339"]}, - "before": {"$ref": "#/$defs/endpointPattern"}, - "after": {"$ref": "#/$defs/endpointPattern"} - } - }, - "relationRequirement": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "plane", "name", "profile_id"], - "properties": { - "kind": {"enum": ["join", "happens_before"]}, - "plane": {"$ref": "#/$defs/plane"}, - "name": {"type": "string", "minLength": 1}, - "profile_id": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"} - } - } - } -} diff --git a/contracts/agent/v1/schemas/requirement-registry.schema.json b/contracts/agent/v1/schemas/requirement-registry.schema.json deleted file mode 100644 index 13b1517a..00000000 --- a/contracts/agent/v1/schemas/requirement-registry.schema.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "requirement-registry.schema.json", - "title": "Agent Requirement Registry v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "requirements"], - "properties": { - "schema_version": {"const": "1.0"}, - "requirements": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "title", "phase", "assertion"], - "properties": { - "id": {"type": "string", "pattern": "^AGENT-[A-Z0-9-]+$"}, - "title": {"type": "string", "minLength": 1}, - "phase": {"enum": ["0A", "0B", "0C", "provider_contract_slice"]}, - "assertion": {"type": "string", "minLength": 1} - } - } - } - } -} diff --git a/contracts/agent/v1/schemas/scenario-catalog.schema.json b/contracts/agent/v1/schemas/scenario-catalog.schema.json deleted file mode 100644 index 584b3958..00000000 --- a/contracts/agent/v1/schemas/scenario-catalog.schema.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "scenario-catalog.schema.json", - "title": "Agent Golden Scenario Catalog v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "fixture_roots", "fixture_path_template", "scenarios"], - "properties": { - "schema_version": {"const": "1.0"}, - "fixture_roots": { - "type": "object", - "additionalProperties": false, - "required": ["legacy", "contract_v1"], - "properties": { - "legacy": {"type": "string", "minLength": 1}, - "contract_v1": {"type": "string", "minLength": 1} - } - }, - "fixture_path_template": {"const": "{fixture_root}/{scenario_id}/{fixture_key}/{plane}.json"}, - "scenarios": { - "type": "array", - "minItems": 17, - "items": {"$ref": "#/$defs/scenario"} - } - }, - "$defs": { - "scenario": { - "type": "object", - "additionalProperties": false, - "required": ["id", "name", "variants"], - "properties": { - "id": {"type": "string", "pattern": "^GT[0-9]{2}$"}, - "name": {"type": "string", "pattern": "^[a-z0-9_]+$"}, - "variants": { - "type": "array", - "minItems": 1, - "items": {"$ref": "#/$defs/variant"} - } - } - }, - "variant": { - "type": "object", - "additionalProperties": false, - "required": [ - "variant_id", "fixture_key", "entrypoint", "pre_state", "steps", "requirements", - "refresh_action", "legacy_expectation", "contract_v1_expectation", "planes" - ], - "properties": { - "variant_id": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "fixture_key": {"type": "string", "pattern": "^GT[0-9]{2}-[a-z0-9-]+$"}, - "entrypoint": {"enum": ["chat_sync", "chat_sse", "scheduled", "handoff_resume", "channel", "message_feedback"]}, - "interaction_mode": {"enum": ["normal", "scheduled_task"]}, - "pre_state": {"type": "string", "pattern": "^[a-z0-9_]+$"}, - "steps": { - "type": "array", - "minItems": 1, - "items": {"type": "string", "pattern": "^[a-z0-9_]+$"} - }, - "legacy_steps": { - "type": "array", - "minItems": 1, - "items": {"type": "string", "pattern": "^[a-z0-9_]+$"} - }, - "contract_v1_steps": { - "type": "array", - "minItems": 1, - "items": {"type": "string", "pattern": "^[a-z0-9_]+$"} - }, - "requirements": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"type": "string", "pattern": "^AGENT-[A-Z0-9-]+$"} - }, - "refresh_action": {"type": "string", "pattern": "^[a-z0-9_]+$"}, - "legacy_expectation": { - "type": "object", - "additionalProperties": false, - "required": ["source"], - "properties": {"source": {"const": "legacy_fixture"}} - }, - "contract_v1_expectation": { - "type": "object", - "additionalProperties": false, - "required": ["terminal"], - "properties": { - "terminal": {"enum": ["completed", "waiting", "failed", "cancelled", "handoff"]}, - "cancel_state": {"enum": ["none", "requested", "propagating", "settled"]}, - "duplicate_execution": {"type": "boolean"} - } - }, - "planes": {"$ref": "#/$defs/planes"} - } - }, - "planes": { - "type": "object", - "additionalProperties": false, - "required": ["provider", "domain", "sse", "db_events", "conversation"], - "properties": { - "provider": {"$ref": "#/$defs/planeApplicability"}, - "domain": {"$ref": "#/$defs/planeApplicability"}, - "sse": {"$ref": "#/$defs/planeApplicability"}, - "db_events": {"$ref": "#/$defs/planeApplicability"}, - "conversation": {"$ref": "#/$defs/planeApplicability"} - } - }, - "planeApplicability": { - "type": "object", - "additionalProperties": false, - "required": ["legacy", "contract_v1"], - "properties": { - "legacy": {"enum": ["required", "not_applicable", "deferred"]}, - "contract_v1": {"enum": ["required", "not_applicable", "deferred"]} - } - } - } -} diff --git a/contracts/agent/v1/schemas/scenario-vocabulary.schema.json b/contracts/agent/v1/schemas/scenario-vocabulary.schema.json deleted file mode 100644 index 81b6ccbb..00000000 --- a/contracts/agent/v1/schemas/scenario-vocabulary.schema.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "scenario-vocabulary.schema.json", - "title": "Agent Scenario Vocabulary v1", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "pre_states", "steps", "refresh_actions"], - "properties": { - "schema_version": {"const": "1.0"}, - "pre_states": {"$ref": "#/$defs/terms"}, - "steps": {"$ref": "#/$defs/terms"}, - "refresh_actions": {"$ref": "#/$defs/terms"} - }, - "$defs": { - "terms": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "meaning"], - "properties": { - "id": {"type": "string", "pattern": "^[a-z0-9_]+$"}, - "meaning": {"type": "string", "minLength": 1} - } - } - } - } -} From 29885d4ba01cf289363d21be86a552ea9d892c82 Mon Sep 17 00:00:00 2001 From: hm1229 Date: Wed, 5 Aug 2026 21:17:34 +0800 Subject: [PATCH 2/3] fix(models): make verified saves atomic --- backend/app/api/model_configs.py | 163 ++++++++++++------ backend/tests/test_model_configs_api.py | 118 +++++++++++-- frontend-enterprise/src/pages/ModelsPage.tsx | 40 ++--- .../chat/components/ModelSetupDialog.tsx | 42 ++--- 4 files changed, 240 insertions(+), 123 deletions(-) diff --git a/backend/app/api/model_configs.py b/backend/app/api/model_configs.py index 5112c06f..ac0ba3c2 100644 --- a/backend/app/api/model_configs.py +++ b/backend/app/api/model_configs.py @@ -12,7 +12,11 @@ from app.db import get_session from app.db.models import AgentModelBinding, ModelConfig, User, utc_now from app.llm import LLMClient, LLMError -from app.llm.model_config_resolver import resolve_model_config_for_verification +from app.llm.model_config_resolver import ( + ResolvedModelConfig, + resolve_model_config_for_verification, + snapshot_model_config, +) from app.llm.model_protocols import ( LEGACY_OPENAI_PROVIDER, ModelApiProtocol, @@ -87,7 +91,9 @@ def model_config_read(row: ModelConfig) -> ModelConfigRead: ) -@router.get("", response_model=list[ModelConfigRead], dependencies=[Depends(require_current_tenant)]) +@router.get( + "", response_model=list[ModelConfigRead], dependencies=[Depends(require_current_tenant)] +) def list_model_configs( tenant_id: str = Query(...), db: Session = Depends(get_session) ) -> list[ModelConfigRead]: @@ -99,6 +105,7 @@ def list_model_configs( @router.post("", response_model=ModelConfigRead) def create_model_config( request: ModelConfigCreateRequest, + verify_before_save: bool = False, db: Session = Depends(get_session), current_user: User = Depends(get_current_user), ) -> ModelConfigRead: @@ -126,6 +133,12 @@ def create_model_config( enabled=False, trust_status="unverified", ) + if verify_before_save and request.enabled: + _verify_candidate_for_save(row) + row.enabled = True + if request.is_default or not _has_available_model(db, request.tenant_id): + _clear_default(db, request.tenant_id) + row.is_default = True db.add(row) _commit_or_conflict(db) db.refresh(row) @@ -136,14 +149,20 @@ def create_model_config( def update_model_config( config_id: str, request: ModelConfigUpdateRequest, + verify_before_save: bool = False, db: Session = Depends(get_session), current_user: User = Depends(get_current_user), ) -> ModelConfigRead: ensure_tenant_admin(request.tenant_id, current_user) row = _get_model_config(db, request.tenant_id, config_id) - protocol = resolve_api_protocol(request.api_protocol, request.provider) if ( - request.api_protocol is not None or request.provider is not None - ) else ModelApiProtocol(row.api_protocol) + has_other_available_model = _has_available_model( + db, request.tenant_id, exclude_config_id=config_id + ) + protocol = ( + resolve_api_protocol(request.api_protocol, request.provider) + if (request.api_protocol is not None or request.provider is not None) + else ModelApiProtocol(row.api_protocol) + ) target_temperature = request.temperature if request.temperature is not None else row.temperature target_tokens = ( request.max_output_tokens @@ -193,7 +212,19 @@ def update_model_config( row.verified_fingerprint = None row.enabled = False row.is_default = False - else: + if verify_before_save and request.enabled is True: + try: + _verify_candidate_for_save(row) + except Exception: + db.rollback() + raise + row.enabled = True + if request.is_default is True or not has_other_available_model: + _clear_default(db, request.tenant_id) + row.is_default = True + elif request.is_default is False: + row.is_default = False + elif not security_changed: if request.enabled is False: row.enabled = False row.is_default = False @@ -285,45 +316,9 @@ def test_model_config( _commit_or_conflict(db) capabilities: list[ModelCapabilityTestResult] = [] output: str | None = None - verification_started = monotonic() try: config = resolve_model_config_for_verification(db, tenant_id, config_id, attempt_id) - for capability_id, max_tokens, probe_timeout in MODEL_VERIFICATION_PROBES: - remaining = MODEL_VERIFICATION_DEADLINE_SECONDS - ( - monotonic() - verification_started - ) - if remaining <= 0: - raise LLMError("MODEL_VERIFICATION_DEADLINE_EXCEEDED") - probe_config = replace( - config, - timeout_seconds=min(probe_timeout, remaining), - max_output_tokens=_verification_probe_tokens( - config.api_protocol, - capability_id, - min(max_tokens, config.max_output_tokens), - ), - ) - probe_client = LLMClient(probe_config) - if capability_id == "text": - output = probe_client.generate_text( - "你是一个连接测试助手。请用一句中文回复连接成功。", - {"message": "ping"}, - ) - elif capability_id == "stream": - stream_text = "".join( - probe_client.generate_text_stream( - "你是一个连接测试助手。", {"message": "请回复 stream-ok"} - ) - ) - if not stream_text.strip(): - raise LLMError("MODEL_EMPTY_OUTPUT") - else: - json_output = probe_client.generate_json( - "只返回 JSON object。", {"message": "返回 {\"ok\": true}"} - ) - if not isinstance(json_output, dict): - raise LLMError("MODEL_INVALID_JSON") - capabilities.append(ModelCapabilityTestResult(id=capability_id, success=True)) + capabilities, output = _run_verification_probes(config) db.refresh(row) if ( row.security_revision != started_security_revision @@ -428,6 +423,66 @@ def test_model_config( raise +def _verify_candidate_for_save(row: ModelConfig) -> None: + attempt_id = uuid4().hex + row.verification_attempt_id = attempt_id + row.verification_attempt_status = "verifying" + row.verification_started_at = utc_now() + row.verification_attempt_error_code = None + config = replace(snapshot_model_config(row), purpose="verification") + try: + _run_verification_probes(config) + except LLMError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + row.trust_status = "verified" + row.verified_at = utc_now() + row.verified_fingerprint = _fingerprint(row) + row.verification_attempt_status = "succeeded" + + +def _run_verification_probes( + config: ResolvedModelConfig, +) -> tuple[list[ModelCapabilityTestResult], str | None]: + capabilities: list[ModelCapabilityTestResult] = [] + output: str | None = None + verification_started = monotonic() + for capability_id, max_tokens, probe_timeout in MODEL_VERIFICATION_PROBES: + remaining = MODEL_VERIFICATION_DEADLINE_SECONDS - (monotonic() - verification_started) + if remaining <= 0: + raise LLMError("MODEL_VERIFICATION_DEADLINE_EXCEEDED") + probe_config = replace( + config, + timeout_seconds=min(probe_timeout, remaining), + max_output_tokens=_verification_probe_tokens( + config.api_protocol, + capability_id, + min(max_tokens, config.max_output_tokens), + ), + ) + probe_client = LLMClient(probe_config) + if capability_id == "text": + output = probe_client.generate_text( + "你是一个连接测试助手。请用一句中文回复连接成功。", + {"message": "ping"}, + ) + elif capability_id == "stream": + stream_text = "".join( + probe_client.generate_text_stream( + "你是一个连接测试助手。", {"message": "请回复 stream-ok"} + ) + ) + if not stream_text.strip(): + raise LLMError("MODEL_EMPTY_OUTPUT") + else: + json_output = probe_client.generate_json( + "只返回 JSON object。", {"message": '返回 {"ok": true}'} + ) + if not isinstance(json_output, dict): + raise LLMError("MODEL_INVALID_JSON") + capabilities.append(ModelCapabilityTestResult(id=capability_id, success=True)) + return capabilities, output + + def _verification_error_code(exc: Exception) -> str: value = str(exc).strip() if value.startswith("MODEL_") and " " not in value: @@ -466,16 +521,16 @@ def _get_model_config(db: Session, tenant_id: str, config_id: str) -> ModelConfi return row -def _has_available_model(db: Session, tenant_id: str) -> bool: - return ( - db.exec( - select(ModelConfig).where( - ModelConfig.tenant_id == tenant_id, - (ModelConfig.enabled == True) | (ModelConfig.is_default == True), # noqa: E712 - ) - ).first() - is not None +def _has_available_model( + db: Session, tenant_id: str, *, exclude_config_id: str | None = None +) -> bool: + statement = select(ModelConfig).where( + ModelConfig.tenant_id == tenant_id, + (ModelConfig.enabled == True) | (ModelConfig.is_default == True), # noqa: E712 ) + if exclude_config_id: + statement = statement.where(ModelConfig.id != exclude_config_id) + return db.exec(statement).first() is not None def _clear_default(db: Session, tenant_id: str) -> None: @@ -512,9 +567,7 @@ def _request_protocol_options( def _validate_sampling( protocol: ModelApiProtocol, temperature: float, max_output_tokens: int ) -> None: - max_temperature = ( - 1 if protocol is ModelApiProtocol.ANTHROPIC_MESSAGES else 2 - ) + max_temperature = 1 if protocol is ModelApiProtocol.ANTHROPIC_MESSAGES else 2 if not 0 <= temperature <= max_temperature: raise HTTPException(status_code=422, detail="MODEL_TEMPERATURE_INVALID") if max_output_tokens <= 0: diff --git a/backend/tests/test_model_configs_api.py b/backend/tests/test_model_configs_api.py index 258f6ff3..a14a275b 100644 --- a/backend/tests/test_model_configs_api.py +++ b/backend/tests/test_model_configs_api.py @@ -3,9 +3,10 @@ import threading from concurrent.futures import ThreadPoolExecutor +import pytest from fastapi import HTTPException from sqlalchemy import text -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, SQLModel, create_engine, select from app.api.model_configs import ( _verification_probe_tokens, @@ -18,6 +19,7 @@ test_model_config as run_model_config_test, ) from app.db.models import AgentModelBinding, ModelConfig, Tenant, User +from app.llm import LLMError from app.llm.schemas import ModelConfigCreateRequest, ModelConfigUpdateRequest from app.security.encryption import encrypt_secret @@ -62,6 +64,58 @@ def test_new_model_config_is_never_enabled_or_default(tmp_path) -> None: assert created.trust_status == "unverified" +def test_verified_create_is_atomic_and_activates_only_after_success(tmp_path, monkeypatch) -> None: + _install_passing_verification_client(monkeypatch) + with _db(tmp_path) as db: + created = create_model_config( + ModelConfigCreateRequest( + tenant_id="tenant_a", + name="Chat", + api_protocol="openai_chat_completions", + api_key="secret", + model="model-a", + enabled=True, + ), + verify_before_save=True, + db=db, + current_user=_admin(), + ) + + assert created.trust_status == "verified" + assert created.enabled is True + assert created.is_default is True + assert len(db.exec(select(ModelConfig)).all()) == 1 + + +def test_failed_verified_create_does_not_leave_disabled_model(tmp_path, monkeypatch) -> None: + class FailingClient: + def __init__(self, _config) -> None: # noqa: ANN001 + pass + + def generate_text(self, _prompt, _payload): # noqa: ANN001 + raise LLMError("Connection error") + + monkeypatch.setattr("app.api.model_configs.LLMClient", FailingClient) + with _db(tmp_path) as db: + with pytest.raises(HTTPException) as exc_info: + create_model_config( + ModelConfigCreateRequest( + tenant_id="tenant_a", + name="Broken", + api_protocol="openai_chat_completions", + api_key="secret", + model="broken-model", + enabled=True, + ), + verify_before_save=True, + db=db, + current_user=_admin(), + ) + + assert exc_info.value.status_code == 502 + assert db.exec(select(ModelConfig)).all() == [] + + def test_gemini_model_config_can_be_created(tmp_path) -> None: with _db(tmp_path) as db: created = create_model_config( @@ -142,12 +196,8 @@ def test_model_config_delete_removes_agent_bindings(tmp_path) -> None: def test_gemini_verification_reserves_tokens_for_visible_output() -> None: from app.llm.model_protocols import ModelApiProtocol - assert _verification_probe_tokens( - ModelApiProtocol.GEMINI_GENERATE_CONTENT, "stream", 32 - ) == 128 - assert _verification_probe_tokens( - ModelApiProtocol.OPENAI_CHAT_COMPLETIONS, "stream", 32 - ) == 32 + assert _verification_probe_tokens(ModelApiProtocol.GEMINI_GENERATE_CONTENT, "stream", 32) == 128 + assert _verification_probe_tokens(ModelApiProtocol.OPENAI_CHAT_COMPLETIONS, "stream", 32) == 32 def test_security_change_invalidates_and_disables_legacy_config(tmp_path) -> None: @@ -178,6 +228,56 @@ def test_security_change_invalidates_and_disables_legacy_config(tmp_path) -> Non assert updated.security_revision == 2 +def test_failed_verified_update_preserves_existing_model(tmp_path, monkeypatch) -> None: + class FailingClient: + def __init__(self, _config) -> None: # noqa: ANN001 + pass + + def generate_text(self, _prompt, _payload): # noqa: ANN001 + raise LLMError("Connection error") + + monkeypatch.setattr("app.api.model_configs.LLMClient", FailingClient) + with _db(tmp_path) as db: + db.add( + ModelConfig( + id="model_a", + tenant_id="tenant_a", + name="Working", + api_key_encrypted=encrypt_secret("secret"), + model="model-a", + trust_status="legacy_trusted", + enabled=True, + is_default=True, + ) + ) + db.commit() + + with pytest.raises(HTTPException) as exc_info: + update_model_config( + "model_a", + ModelConfigUpdateRequest( + tenant_id="tenant_a", + name="Broken edit", + model="broken-model", + enabled=True, + is_default=True, + ), + verify_before_save=True, + db=db, + current_user=_admin(), + ) + + assert exc_info.value.status_code == 502 + db.expire_all() + row = db.get(ModelConfig, "model_a") + assert row is not None + assert row.name == "Working" + assert row.model == "model-a" + assert row.trust_status == "legacy_trusted" + assert row.enabled is True + assert row.is_default is True + + def test_disabling_default_clears_default_in_same_update(tmp_path) -> None: with _db(tmp_path) as db: db.add( @@ -285,9 +385,7 @@ def test_read_returns_only_current_protocol_options(tmp_path) -> None: }, ) - assert model_config_read(row).protocol_options == { - "thinking": {"type": "disabled"} - } + assert model_config_read(row).protocol_options == {"thinking": {"type": "disabled"}} def test_verification_runs_bounded_text_stream_and_json_probes(tmp_path, monkeypatch) -> None: diff --git a/frontend-enterprise/src/pages/ModelsPage.tsx b/frontend-enterprise/src/pages/ModelsPage.tsx index 8a7e1332..bb98bab8 100644 --- a/frontend-enterprise/src/pages/ModelsPage.tsx +++ b/frontend-enterprise/src/pages/ModelsPage.tsx @@ -98,7 +98,7 @@ export default function ModelsPage({ const [selected, setSelected] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [saving, setSaving] = useState(false); - const [saveStage, setSaveStage] = useState<'saving' | 'testing' | 'activating' | null>(null); + const [saveStage, setSaveStage] = useState<'saving' | 'testing' | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const testingModelIdsRef = useRef(new Set()); @@ -214,43 +214,27 @@ export default function ModelsPage({ temperature, max_output_tokens: maxOutputTokens, extra_body: extraBody, - // Activation is completed only after the automatic verification below. - is_default: false, - enabled: false, + is_default: form.enabled && form.is_default, + enabled: form.enabled, api_key: form.api_key || undefined, }; setSaving(true); - setSaveStage('saving'); + setSaveStage(form.enabled ? 'testing' : 'saving'); try { - let saved: ModelConfigRead; + const verifyQuery = form.enabled ? '?verify_before_save=true' : ''; if (selected) { - saved = await api.put(`/api/enterprise/model-configs/${selected.id}`, payload); + await api.put( + `/api/enterprise/model-configs/${selected.id}${verifyQuery}`, + payload, + ); } else { - saved = await api.post('/api/enterprise/model-configs', payload); + await api.post(`/api/enterprise/model-configs${verifyQuery}`, payload); } - let completed = true; if (form.enabled) { - setSaveStage('testing'); - const verified = await test(saved); - if (verified) { - setSaveStage('activating'); - await api.put(`/api/enterprise/model-configs/${saved.id}`, { - tenant_id: TENANT_ID, - enabled: true, - is_default: form.is_default, - }); - notify.success(form.is_default ? '测试通过,已启用并设为默认模型' : '测试通过,已启用'); - } else { - completed = false; - if (!selected) { - setSelected(saved); - setForm((current) => ({ ...current, api_key: '' })); - } - } + notify.success(form.is_default ? '测试通过,已启用并设为默认模型' : '测试通过,已启用'); } else { notify.success('已保存'); } - if (!completed) return; setEditorOpen(false); setSelected(null); setForm(BLANK_MODEL_FORM); @@ -642,7 +626,7 @@ export default function ModelsPage({ className="h-[32px] w-[80px] rounded-[10px] bg-[#18181a] px-[12px] text-[14px] font-normal text-white hover:bg-[#303030]" > {saving && } - {saveStage === 'testing' ? '测试中' : saveStage === 'activating' ? '启用中' : saving ? '保存中' : '保存'} + {saveStage === 'testing' ? '测试并保存中' : saving ? '保存中' : '保存'} diff --git a/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx b/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx index 2bcafeac..e2e3a436 100644 --- a/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx +++ b/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx @@ -110,39 +110,21 @@ export default function ModelSetupDialog({ model, temperature, max_output_tokens: maxOutputTokens, - is_default: false, - enabled: false, + is_default: true, + enabled: true, }; const saved = savedModelId - ? await api.put(`/api/enterprise/model-configs/${savedModelId}`, payload) - : await api.post('/api/enterprise/model-configs', payload); + ? await api.put( + `/api/enterprise/model-configs/${savedModelId}?verify_before_save=true`, + payload, + ) + : await api.post( + '/api/enterprise/model-configs?verify_before_save=true', + payload, + ); setSavedModelId(saved.id); - - const result = await api.post<{ - success: boolean; - message: string; - output?: string; - activated: boolean; - model?: ModelConfigRead; - }>( - `/api/enterprise/model-configs/${saved.id}/test?tenant_id=${encodeURIComponent(tenantId)}&activate_if_initial=true`, - ); - if (!result.success) { - setTestResult({ success: false, message: result.message ? t(result.message) : t('模型连接失败,请检查配置后重试。') }); - return; - } - - const activated = result.model?.enabled - ? result.model - : (await api.get( - `/api/enterprise/model-configs?tenant_id=${encodeURIComponent(tenantId)}`, - )).find((item) => item.enabled && item.is_default); - if (!activated) { - setTestResult({ success: false, message: t('模型测试通过,但首次激活未完成,请刷新后重试。') }); - return; - } - setTestResult({ success: true, message: result.output || (result.message ? t(result.message) : t('模型连接成功。')) }); - onConfigured(activated); + setTestResult({ success: true, message: t('模型连接成功。') }); + onConfigured(saved); } catch (error) { setTestResult({ success: false, From 59e6e2fe886c6d6fed3118e72475b31263c040f3 Mon Sep 17 00:00:00 2001 From: hm1229 Date: Thu, 6 Aug 2026 11:15:17 +0800 Subject: [PATCH 3/3] feat(logs): add JSON batch export --- backend/app/api/sessions.py | 134 ++++++++- .../test_enterprise_session_visibility.py | 163 +++++++++-- frontend-enterprise/src/api/client.ts | 15 + frontend-enterprise/src/i18n/en.json | 9 + .../pages/dashboard/ConversationLogsTab.tsx | 257 +++++++++++++++--- 5 files changed, 516 insertions(+), 62 deletions(-) diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py index 9237dffa..cb00ceb8 100644 --- a/backend/app/api/sessions.py +++ b/backend/app/api/sessions.py @@ -1,6 +1,12 @@ from __future__ import annotations +import json +from datetime import UTC, datetime + from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.encoders import jsonable_encoder +from fastapi.responses import Response +from pydantic import BaseModel, Field from sqlmodel import Session, select from app.api.chat import _build_turn_traces, message_read, session_read @@ -24,6 +30,12 @@ router = APIRouter(prefix="/api/enterprise/sessions", tags=["enterprise:sessions"]) +SESSION_LOG_EXPORT_SCHEMA = "staffdeck.conversation-log.v1" + + +class SessionLogExportRequest(BaseModel): + session_ids: list[str] = Field(min_length=1, max_length=500) + @router.get("") def list_sessions( @@ -47,6 +59,51 @@ def list_sessions( return _session_payloads(db, rows) +@router.post("/export") +def export_session_logs( + request: SessionLogExportRequest, + tenant_id: str = Query(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + _ensure_request_tenant(tenant_id, current_user) + session_ids = list(dict.fromkeys(request.session_ids)) + rows = [ + _get_visible_chat_session(db, tenant_id, session_id, current_user) + for session_id in session_ids + ] + details = _session_details_payload(db, tenant_id, rows) + exported_at = datetime.now(UTC) + return _json_download_response( + { + "schema_version": SESSION_LOG_EXPORT_SCHEMA, + "exported_at": exported_at, + "count": len(details), + "items": details, + }, + f"staffdeck-conversation-logs-{exported_at.strftime('%Y%m%d-%H%M%S')}.json", + ) + + +@router.get("/{session_id}/export") +def export_session_log( + session_id: str, + tenant_id: str = Query(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_session), +) -> Response: + _ensure_request_tenant(tenant_id, current_user) + row = _get_visible_chat_session(db, tenant_id, session_id, current_user) + return _json_download_response( + { + "schema_version": SESSION_LOG_EXPORT_SCHEMA, + "exported_at": datetime.now(UTC), + "item": _session_detail_payload(db, tenant_id, row), + }, + f"staffdeck-conversation-log-{_safe_filename_part(session_id)}.json", + ) + + @router.get("/{session_id}") def get_session_detail( session_id: str, @@ -56,33 +113,80 @@ def get_session_detail( ) -> dict: _ensure_request_tenant(tenant_id, current_user) row = _get_visible_chat_session(db, tenant_id, session_id, current_user) + return _session_detail_payload(db, tenant_id, row) + + +def _session_detail_payload(db: Session, tenant_id: str, row: ChatSession) -> dict: + return _session_details_payload(db, tenant_id, [row])[0] + + +def _session_details_payload( + db: Session, + tenant_id: str, + rows: list[ChatSession], +) -> list[dict]: + if not rows: + return [] + session_ids = [row.id for row in rows] messages = db.exec( select(Message) - .where(Message.tenant_id == tenant_id, Message.session_id == session_id) + .where(Message.tenant_id == tenant_id, Message.session_id.in_(session_ids)) .order_by(Message.created_at) ).all() events = db.exec( select(AgentEvent) - .where(AgentEvent.tenant_id == tenant_id, AgentEvent.session_id == session_id) + .where(AgentEvent.tenant_id == tenant_id, AgentEvent.session_id.in_(session_ids)) .order_by(AgentEvent.created_at) ).all() feedback_rows = db.exec( select(MessageFeedback) .where( MessageFeedback.tenant_id == tenant_id, - MessageFeedback.session_id == session_id, + MessageFeedback.session_id.in_(session_ids), ) .order_by(MessageFeedback.updated_at.desc()) ).all() - feedback_by_message = {item.message_id: item for item in feedback_rows} skills = db.exec(select(Skill).where(Skill.tenant_id == tenant_id)).all() skill_names = {skill.skill_id: skill.name for skill in skills} + session_payload_by_id = {str(payload["id"]): payload for payload in _session_payloads(db, rows)} + messages_by_session = _group_by_session_id(messages) + events_by_session = _group_by_session_id(events) + feedback_by_session = _group_by_session_id(feedback_rows) + return [ + _build_session_detail_payload( + db, + session_payload_by_id[row.id], + messages_by_session.get(row.id, []), + events_by_session.get(row.id, []), + feedback_by_session.get(row.id, []), + skill_names, + ) + for row in rows + ] + + +def _group_by_session_id(rows: list) -> dict[str, list]: + grouped: dict[str, list] = {} + for row in rows: + grouped.setdefault(row.session_id, []).append(row) + return grouped + + +def _build_session_detail_payload( + db: Session, + session_payload: dict, + messages: list[Message], + events: list[AgentEvent], + feedback_rows: list[MessageFeedback], + skill_names: dict[str, str], +) -> dict: + feedback_by_message = {item.message_id: item for item in feedback_rows} traces = enrich_turn_traces_with_timings( _build_turn_traces(messages, events, skill_names), events, ) return { - "session": _session_payloads(db, [row])[0], + "session": session_payload, "messages": [ _message_payload(message, feedback_by_message.get(message.id), db) for message in messages @@ -112,6 +216,26 @@ def get_session_detail( } +def _json_download_response(payload: object, filename: str) -> Response: + content = json.dumps( + jsonable_encoder(payload), + ensure_ascii=False, + indent=2, + ).encode("utf-8") + return Response( + content=content, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _safe_filename_part(value: str) -> str: + safe = "".join( + character if character.isalnum() or character in "-_" else "-" for character in value + ) + return safe.strip("-") or "session" + + def _message_payload( message: Message, feedback: MessageFeedback | None, diff --git a/backend/tests/test_enterprise_session_visibility.py b/backend/tests/test_enterprise_session_visibility.py index 2abe76dd..5c1923ae 100644 --- a/backend/tests/test_enterprise_session_visibility.py +++ b/backend/tests/test_enterprise_session_visibility.py @@ -1,3 +1,4 @@ +import json from types import SimpleNamespace import pytest @@ -11,9 +12,18 @@ list_feedback_sessions, reanalyze_feedback, ) -from app.api.sessions import get_session_detail, list_sessions, reset_session +from app.api.sessions import ( + SESSION_LOG_EXPORT_SCHEMA, + SessionLogExportRequest, + export_session_log, + export_session_logs, + get_session_detail, + list_sessions, + reset_session, +) from app.core.task_frame_store import TaskFrameStore from app.db.models import ( + AgentEvent, AgentProfile, ChatSession, HarnessRunRecord, @@ -40,7 +50,9 @@ def _test_session() -> Session: def _seed(db: Session) -> dict[str, User]: db.add(Tenant(id="tenant_demo", name="Demo")) - admin = User(id="admin_user", tenant_id="tenant_demo", username="admin", role="admin", password_hash="x") + admin = User( + id="admin_user", tenant_id="tenant_demo", username="admin", role="admin", password_hash="x" + ) owner = User(id="owner_user", tenant_id="tenant_demo", username="owner", password_hash="x") member = User(id="member_user", tenant_id="tenant_demo", username="member", password_hash="x") wechat_user = User( @@ -112,7 +124,9 @@ def _seed(db: Session) -> dict[str, User]: def test_agent_creator_sees_all_sessions_of_the_agent() -> None: with _test_session() as db: users = _seed(db) - rows = list_sessions("tenant_demo", agent_id="agent_emp", current_user=users["owner"], db=db) + rows = list_sessions( + "tenant_demo", agent_id="agent_emp", current_user=users["owner"], db=db + ) session_ids = {row["id"] for row in rows} assert session_ids == {"session_owner", "session_member", "session_channel"} @@ -120,14 +134,18 @@ def test_agent_creator_sees_all_sessions_of_the_agent() -> None: def test_admin_sees_all_sessions_with_agent_id() -> None: with _test_session() as db: users = _seed(db) - rows = list_sessions("tenant_demo", agent_id="agent_emp", current_user=users["admin"], db=db) + rows = list_sessions( + "tenant_demo", agent_id="agent_emp", current_user=users["admin"], db=db + ) assert {row["id"] for row in rows} == {"session_owner", "session_member", "session_channel"} def test_member_only_sees_own_sessions() -> None: with _test_session() as db: users = _seed(db) - rows = list_sessions("tenant_demo", agent_id="agent_emp", current_user=users["member"], db=db) + rows = list_sessions( + "tenant_demo", agent_id="agent_emp", current_user=users["member"], db=db + ) assert [row["id"] for row in rows] == ["session_member"] # 无 agent_id 时 admin 也只看自己 @@ -159,13 +177,19 @@ def test_detail_allowed_for_owner_admin_and_agent_creator() -> None: with _test_session() as db: users = _seed(db) # 会话属主 - own = get_session_detail("session_member", "tenant_demo", current_user=users["member"], db=db) + own = get_session_detail( + "session_member", "tenant_demo", current_user=users["member"], db=db + ) assert own["session"]["id"] == "session_member" # admin - by_admin = get_session_detail("session_member", "tenant_demo", current_user=users["admin"], db=db) + by_admin = get_session_detail( + "session_member", "tenant_demo", current_user=users["admin"], db=db + ) assert by_admin["session"]["id"] == "session_member" # agent 创建者查看渠道会话 - by_creator = get_session_detail("session_channel", "tenant_demo", current_user=users["owner"], db=db) + by_creator = get_session_detail( + "session_channel", "tenant_demo", current_user=users["owner"], db=db + ) assert by_creator["session"]["id"] == "session_channel" @@ -173,13 +197,103 @@ def test_detail_404_for_other_members() -> None: with _test_session() as db: users = _seed(db) with pytest.raises(HTTPException) as exc_info: - get_session_detail("session_channel", "tenant_demo", current_user=users["member"], db=db) + get_session_detail( + "session_channel", "tenant_demo", current_user=users["member"], db=db + ) assert exc_info.value.status_code == 404 with pytest.raises(HTTPException) as exc_info: get_session_detail("session_owner", "tenant_demo", current_user=users["member"], db=db) assert exc_info.value.status_code == 404 +def test_single_session_json_export_contains_complete_log_envelope() -> None: + with _test_session() as db: + users = _seed(db) + db.add_all( + [ + Message( + id="message_user", + tenant_id="tenant_demo", + session_id="session_channel", + role="user", + content="查询报销制度", + ), + Message( + id="message_assistant", + tenant_id="tenant_demo", + session_id="session_channel", + role="assistant", + content="这是制度答复", + ), + MessageFeedback( + tenant_id="tenant_demo", + session_id="session_channel", + message_id="message_assistant", + user_id=users["owner"].id, + rating="up", + ), + AgentEvent( + tenant_id="tenant_demo", + session_id="session_channel", + event_type="task.completed", + payload_json={"task_id": "task_export"}, + ), + ] + ) + db.commit() + response = export_session_log( + "session_channel", + "tenant_demo", + current_user=users["owner"], + db=db, + ) + + payload = json.loads(response.body) + assert response.media_type == "application/json" + assert response.headers["content-disposition"] == ( + 'attachment; filename="staffdeck-conversation-log-session_channel.json"' + ) + assert payload["schema_version"] == SESSION_LOG_EXPORT_SCHEMA + assert payload["item"]["session"]["id"] == "session_channel" + assert [message["content"] for message in payload["item"]["messages"]] == [ + "查询报销制度", + "这是制度答复", + ] + assert payload["item"]["feedback"][0]["rating"] == "up" + assert payload["item"]["events"][0]["payload"] == {"task_id": "task_export"} + assert "traces" in payload["item"] + + +def test_batch_json_export_preserves_order_deduplicates_and_checks_visibility() -> None: + with _test_session() as db: + users = _seed(db) + response = export_session_logs( + SessionLogExportRequest( + session_ids=["session_channel", "session_owner", "session_channel"] + ), + "tenant_demo", + current_user=users["owner"], + db=db, + ) + + payload = json.loads(response.body) + assert payload["schema_version"] == SESSION_LOG_EXPORT_SCHEMA + assert payload["count"] == 2 + assert [item["session"]["id"] for item in payload["items"]] == [ + "session_channel", + "session_owner", + ] + + with pytest.raises(HTTPException) as exc_info: + export_session_logs( + SessionLogExportRequest(session_ids=["session_member", "session_channel"]), + "tenant_demo", + current_user=users["member"], + db=db, + ) + assert exc_info.value.status_code == 404 + + def test_reset_allowed_for_agent_creator_and_admin_only() -> None: with _test_session() as db: users = _seed(db) @@ -188,7 +302,9 @@ def test_reset_allowed_for_agent_creator_and_admin_only() -> None: assert exc_info.value.status_code == 404 # agent 创建者可重置渠道会话 - payload = reset_session("session_channel", "tenant_demo", current_user=users["owner"], db=db) + payload = reset_session( + "session_channel", "tenant_demo", current_user=users["owner"], db=db + ) assert payload["id"] == "session_channel" assert payload["active_skill_id"] is None row = db.get(ChatSession, "session_channel") @@ -196,7 +312,9 @@ def test_reset_allowed_for_agent_creator_and_admin_only() -> None: assert row.status == "active" # admin 也可重置 - admin_payload = reset_session("session_member", "tenant_demo", current_user=users["admin"], db=db) + admin_payload = reset_session( + "session_member", "tenant_demo", current_user=users["admin"], db=db + ) assert admin_payload["id"] == "session_member" @@ -271,7 +389,9 @@ def test_reset_clears_harness_execution_state_and_preserves_turn_receipt() -> No def test_augment_fields_channel_and_identity() -> None: with _test_session() as db: users = _seed(db) - rows = list_sessions("tenant_demo", agent_id="agent_emp", current_user=users["owner"], db=db) + rows = list_sessions( + "tenant_demo", agent_id="agent_emp", current_user=users["owner"], db=db + ) by_id = {row["id"]: row for row in rows} channel_row = by_id["session_channel"] @@ -285,7 +405,9 @@ def test_augment_fields_channel_and_identity() -> None: assert web_row["session_display_name"] is None # detail 同样带 augment 字段 - detail = get_session_detail("session_channel", "tenant_demo", current_user=users["admin"], db=db) + detail = get_session_detail( + "session_channel", "tenant_demo", current_user=users["admin"], db=db + ) assert detail["session"]["channel"] == "wechat" assert detail["session"]["session_display_name"] == "微信用户 ab12cd34" @@ -366,12 +488,15 @@ def test_feedback_scope_matches_agent_session_visibility(monkeypatch: pytest.Mon db=db, ) assert [row["session_id"] for row in member_rows] == ["session_member"] - assert get_feedback_session_detail( - "session_channel", - "tenant_demo", - current_user=users["owner"], - db=db, - )["session"]["id"] == "session_channel" + assert ( + get_feedback_session_detail( + "session_channel", + "tenant_demo", + current_user=users["owner"], + db=db, + )["session"]["id"] + == "session_channel" + ) with pytest.raises(HTTPException) as member_error: get_feedback_session_detail( diff --git a/frontend-enterprise/src/api/client.ts b/frontend-enterprise/src/api/client.ts index 89ae7f95..cda87b54 100644 --- a/frontend-enterprise/src/api/client.ts +++ b/frontend-enterprise/src/api/client.ts @@ -90,6 +90,21 @@ export const api = { } return response.blob(); }, + postBlob: async (path: string, body: unknown) => { + const response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeader(), + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text(); + throw new ApiError(response.status, text, response.statusText); + } + return response.blob(); + }, }; export async function uploadChatAttachments( diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 2a1cbe8c..66ad31a8 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -410,10 +410,19 @@ "对话记录": "Conversation Record", "对话任务": "Conversation Task", "对话日志": "Conversation Logs", + "对话日志 JSON 已导出": "Conversation log JSON exported", "对话日志分页": "Conversation Logs Pagination", "对话日志记录每轮输入输出": "Conversation logs record the input and output of every turn.", "对话日志筛选": "Conversation Logs Filter", "对话日志详情": "Conversation Logs Details", + "导出对话日志失败": "Failed to export conversation log", + "导出筛选结果({1})": "Export filtered results ({1})", + "导出已选({1})": "Export selected ({1})", + "单次最多导出 500 条对话日志,请缩小筛选范围后重试": "You can export up to 500 conversation logs at a time. Narrow the filters and try again.", + "批量导出对话日志失败": "Failed to export conversation logs", + "选择当前页对话日志": "Select conversation logs on this page", + "选择对话日志 {1}": "Select conversation log {1}", + "已导出 {1} 条对话日志": "Exported {1} conversation logs", "对话蒸馏": "Conversation Distillation", "多": "Many", "发布": "Publish", diff --git a/frontend-enterprise/src/pages/dashboard/ConversationLogsTab.tsx b/frontend-enterprise/src/pages/dashboard/ConversationLogsTab.tsx index 8606eb0e..e15713e4 100644 --- a/frontend-enterprise/src/pages/dashboard/ConversationLogsTab.tsx +++ b/frontend-enterprise/src/pages/dashboard/ConversationLogsTab.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Clock, + Download, FileSearch, GitBranch, + LoaderCircle, RefreshCw, Workflow, Wrench, @@ -18,6 +20,7 @@ import { Dialog, DialogContent, DialogTitle, + Checkbox, Select as UISelect, SelectContent, SelectItem, @@ -98,6 +101,8 @@ export default function ConversationLogsTab() { const [loading, setLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [reanalyzingId, setReanalyzingId] = useState(null); + const [selectedSessionIds, setSelectedSessionIds] = useState>(() => new Set()); + const [exportingKey, setExportingKey] = useState(''); useEffect(() => { const onScopeChange = (event: Event) => { @@ -197,6 +202,82 @@ export default function ConversationLogsTab() { `${filter}:${conversationUserId}`, ); + useEffect(() => { + const visibleIds = new Set(filteredRows.map((row) => row.id)); + setSelectedSessionIds((current) => { + const next = new Set([...current].filter((sessionId) => visibleIds.has(sessionId))); + if (next.size === current.size && [...next].every((sessionId) => current.has(sessionId))) { + return current; + } + return next; + }); + }, [filteredRows]); + + const pageSessionIds = pagination.pagedItems.map((row) => row.id); + const allPageRowsSelected = + pageSessionIds.length > 0 && pageSessionIds.every((sessionId) => selectedSessionIds.has(sessionId)); + const somePageRowsSelected = pageSessionIds.some((sessionId) => selectedSessionIds.has(sessionId)); + const batchRows = selectedSessionIds.size + ? filteredRows.filter((row) => selectedSessionIds.has(row.id)) + : filteredRows; + + const toggleSessionSelection = (sessionId: string, selected: boolean) => { + setSelectedSessionIds((current) => { + const next = new Set(current); + if (selected) next.add(sessionId); + else next.delete(sessionId); + return next; + }); + }; + + const togglePageSelection = (selected: boolean) => { + setSelectedSessionIds((current) => { + const next = new Set(current); + pageSessionIds.forEach((sessionId) => { + if (selected) next.add(sessionId); + else next.delete(sessionId); + }); + return next; + }); + }; + + const exportSingleSession = async (row: ConversationLogRow) => { + setExportingKey(row.id); + try { + const blob = await api.blob( + `/api/enterprise/sessions/${encodeURIComponent(row.id)}/export?tenant_id=${TENANT_ID}`, + ); + downloadBlob(blob, `staffdeck-conversation-log-${safeFilenamePart(row.id)}.json`); + notify.success('对话日志 JSON 已导出'); + } catch (error) { + notify.error(error instanceof Error ? error.message : '导出对话日志失败'); + } finally { + setExportingKey(''); + } + }; + + const exportBatch = async () => { + const sessionIds = batchRows.map((row) => row.id); + if (sessionIds.length === 0) return; + if (sessionIds.length > 500) { + notify.error('单次最多导出 500 条对话日志,请缩小筛选范围后重试'); + return; + } + setExportingKey('batch'); + try { + const blob = await api.postBlob( + `/api/enterprise/sessions/export?tenant_id=${TENANT_ID}`, + { session_ids: sessionIds }, + ); + downloadBlob(blob, `staffdeck-conversation-logs-${filenameTimestamp()}.json`); + notify.success(`已导出 ${sessionIds.length} 条对话日志`); + } catch (error) { + notify.error(error instanceof Error ? error.message : '批量导出对话日志失败'); + } finally { + setExportingKey(''); + } + }; + const openDetail = async (row: ConversationLogRow) => { setDetailLoading(true); try { @@ -239,6 +320,25 @@ export default function ConversationLogsTab() { }; const columns: DataTableColumn[] = [ + { + key: 'selection', + title: ( + togglePageSelection(checked === true)} + /> + ), + width: 46, + align: 'center', + render: (row) => ( + toggleSessionSelection(row.id, checked === true)} + /> + ), + }, { key: 'title', title: '对话任务', @@ -316,16 +416,31 @@ export default function ConversationLogsTab() { { key: 'actions', title: '操作', - width: 90, + width: 150, render: (row) => ( - void openDetail(row)} - className="h-auto p-0 text-[12px] font-normal text-[#1a71ff] hover:text-[#4a8dff] hover:no-underline disabled:text-[#c0c6d4]" - > - 查看 - +
+ void exportSingleSession(row)} + className="h-auto gap-[4px] p-0 text-[12px] font-normal text-[#1a71ff] hover:text-[#4a8dff] hover:no-underline disabled:text-[#c0c6d4]" + > + {exportingKey === row.id ? ( + + ) : ( + + )} + JSON + + void openDetail(row)} + className="h-auto p-0 text-[12px] font-normal text-[#1a71ff] hover:text-[#4a8dff] hover:no-underline disabled:text-[#c0c6d4]" + > + 查看 + +
), }, ]; @@ -333,9 +448,16 @@ export default function ConversationLogsTab() { const renderMobileCard = (row: ConversationLogRow) => (
- - {row.title || row.summary || row.last_agent_question || row.id} - +
+ toggleSessionSelection(row.id, checked === true)} + /> + + {row.title || row.summary || row.last_agent_question || row.id} + +
{row.downFeedback && 差评} {row.upFeedback && 好评} @@ -357,7 +479,20 @@ export default function ConversationLogsTab() { {row.session_display_name || row.session_username || '-'}
-
+
+ void exportSingleSession(row)} + className="h-auto gap-[4px] p-0 text-[12px] font-normal text-[#1a71ff] hover:text-[#4a8dff] hover:no-underline disabled:text-[#c0c6d4]" + > + {exportingKey === row.id ? ( + + ) : ( + + )} + JSON +
-
@@ -788,3 +940,32 @@ function analysisStatusLabel(status?: string): string { if (status === 'needs_model') return '未配置模型'; return status || '未知'; } + +function downloadBlob(blob: Blob, filename: string): void { + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(objectUrl); +} + +function safeFilenamePart(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session'; +} + +function filenameTimestamp(): string { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +}