808 lines
31 KiB
Python
808 lines
31 KiB
Python
"""Agent 版本校验、运行快照和多步骤执行编排。
|
||
|
||
运行时只使用已冻结的版本配置,确保模型、Skill、输入映射和授权动作可以事后还原。
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
from copy import deepcopy
|
||
from time import monotonic
|
||
from typing import Any
|
||
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.db.models import Max
|
||
from django.utils import timezone
|
||
|
||
from apps.agents.models import (
|
||
AgentDefinition,
|
||
AgentRun,
|
||
AgentRunAttempt,
|
||
AgentRunEvent,
|
||
AgentRunStep,
|
||
AgentStepBinding,
|
||
AgentVersion,
|
||
)
|
||
from apps.audit.models import OperationAuditLog
|
||
from apps.modelhub.models import ModelInvocation
|
||
from apps.modelhub.services import execute_invocation
|
||
from apps.skills.services import (
|
||
SkillValidationError,
|
||
render_prompt,
|
||
validate_json_schema,
|
||
validate_payload,
|
||
)
|
||
|
||
SOURCE_PATH = re.compile(
|
||
r"^\$(?:input(?:\.[A-Za-z_][A-Za-z0-9_]*)*|steps\.[A-Za-z_][A-Za-z0-9_-]*\.output(?:\.[A-Za-z_][A-Za-z0-9_]*)*)$"
|
||
)
|
||
|
||
|
||
class AgentValidationError(ValueError):
|
||
pass
|
||
|
||
|
||
@transaction.atomic
|
||
def append_run_event(
|
||
run: AgentRun | int,
|
||
event_type: str,
|
||
payload: dict | None = None,
|
||
) -> AgentRunEvent:
|
||
run_id = run.id if isinstance(run, AgentRun) else run
|
||
locked_run = AgentRun.objects.select_for_update().get(id=run_id)
|
||
if event_type in {"completed", "failed", "cancelled", "timed_out"}:
|
||
existing = locked_run.events.order_by("-sequence").first()
|
||
if existing is not None and existing.event_type == event_type:
|
||
return existing
|
||
last_sequence = locked_run.events.aggregate(value=Max("sequence"))["value"] or 0
|
||
event_payload = {
|
||
"run_status": locked_run.status,
|
||
"progress_stage": locked_run.progress_stage,
|
||
"progress_message": locked_run.progress_message,
|
||
**(payload or {}),
|
||
}
|
||
return AgentRunEvent.objects.create(
|
||
run=locked_run,
|
||
sequence=last_sequence + 1,
|
||
event_type=event_type,
|
||
payload=event_payload,
|
||
)
|
||
|
||
|
||
def _set_run_progress(run: AgentRun, stage: str, message: str) -> None:
|
||
run.progress_stage = stage
|
||
run.progress_message = message
|
||
run.save(update_fields=["progress_stage", "progress_message"])
|
||
event_type = stage
|
||
if stage == "completed" and run.input_payload.get("draft_public_id"):
|
||
event_type = "extraction_completed"
|
||
append_run_event(
|
||
run,
|
||
event_type,
|
||
{"current_step": run.current_step, "message": message},
|
||
)
|
||
|
||
|
||
def _step_progress(code: str, name: str) -> tuple[str, str]:
|
||
if code == "facts":
|
||
return "extracting", "正在解析沟通记录"
|
||
if code == "draft":
|
||
return "generating", "正在生成工单草稿"
|
||
return "executing", f"正在执行:{name}"
|
||
|
||
|
||
def validate_agent_version(version: AgentVersion, *, publishing: bool = False) -> None:
|
||
"""校验步骤顺序、模型能力、Skill 状态、输入映射和授权动作。"""
|
||
|
||
validate_json_schema(version.input_schema, "Agent 输入 Schema")
|
||
if version.example_input:
|
||
validate_payload(version.input_schema, version.example_input, "Agent 示例输入")
|
||
if (
|
||
not isinstance(version.allowed_actions, list)
|
||
or any(
|
||
not isinstance(action, str) or not action.strip()
|
||
for action in version.allowed_actions
|
||
)
|
||
or len(set(version.allowed_actions)) != len(version.allowed_actions)
|
||
):
|
||
raise AgentValidationError("Agent 授权动作必须是无重复的非空字符串列表")
|
||
if version.temperature_override is not None and not 0 <= version.temperature_override <= 2:
|
||
raise AgentValidationError("Temperature 必须在 0 到 2 之间")
|
||
if (
|
||
version.max_output_tokens_override is not None
|
||
and not 1 <= version.max_output_tokens_override <= 32768
|
||
):
|
||
raise AgentValidationError("最大输出 Token 超出允许范围")
|
||
steps = list(
|
||
version.steps.select_related(
|
||
"skill_version__definition",
|
||
"model_deployment__provider",
|
||
"agent_version__model_deployment__provider",
|
||
)
|
||
)
|
||
if not steps:
|
||
raise AgentValidationError("Agent 至少需要一个 Skill 步骤")
|
||
if len(steps) > 10:
|
||
raise AgentValidationError("Agent 最多允许 10 个步骤")
|
||
known_steps: set[str] = set()
|
||
for step in steps:
|
||
deployment = step.model_deployment or version.model_deployment
|
||
if deployment.capability not in {"text_chat", "vision_chat"}:
|
||
raise AgentValidationError(
|
||
f"步骤 {step.code} 必须绑定文本或视觉模型,当前为 {deployment.capability}"
|
||
)
|
||
if publishing and (not deployment.enabled or not deployment.provider.enabled):
|
||
raise AgentValidationError(f"步骤 {step.code} 绑定的模型部署未启用")
|
||
if publishing and step.skill_version.status != "published":
|
||
raise AgentValidationError(f"步骤 {step.code} 必须绑定已发布 Skill")
|
||
for target, source in step.input_mapping.items():
|
||
if not isinstance(target, str) or not target:
|
||
raise AgentValidationError(f"步骤 {step.code} 的映射字段无效")
|
||
if not isinstance(source, str) or not SOURCE_PATH.fullmatch(source):
|
||
raise AgentValidationError(f"步骤 {step.code} 的映射来源无效: {source}")
|
||
if source.startswith("$steps."):
|
||
source_step = source.split(".")[1]
|
||
if source_step not in known_steps:
|
||
raise AgentValidationError(
|
||
f"步骤 {step.code} 只能引用之前步骤的输出: {source_step}"
|
||
)
|
||
known_steps.add(step.code)
|
||
|
||
|
||
def _resolve_source(path: str, run_input: dict, step_outputs: dict[str, dict]) -> Any:
|
||
parts = path.removeprefix("$").split(".")
|
||
if parts[0] == "input":
|
||
value: Any = run_input
|
||
tail = parts[1:]
|
||
else:
|
||
value = step_outputs.get(parts[1], {}).get("output")
|
||
tail = parts[3:]
|
||
for part in tail:
|
||
if not isinstance(value, dict) or part not in value:
|
||
raise AgentValidationError(f"映射来源不存在: {path}")
|
||
value = value[part]
|
||
return deepcopy(value)
|
||
|
||
|
||
def map_step_input(
|
||
binding: AgentStepBinding,
|
||
run_input: dict,
|
||
step_outputs: dict[str, dict],
|
||
) -> dict:
|
||
return {
|
||
target: _resolve_source(source, run_input, step_outputs)
|
||
for target, source in binding.input_mapping.items()
|
||
}
|
||
|
||
|
||
def ensure_agent_draft(definition: AgentDefinition, user=None) -> AgentVersion:
|
||
existing = definition.versions.filter(status="draft").first()
|
||
if existing:
|
||
return existing
|
||
source = definition.active_version
|
||
if source is None:
|
||
raise AgentValidationError("Agent 没有可克隆的已发布版本")
|
||
latest = definition.versions.order_by("-version").values_list("version", flat=True).first() or 0
|
||
draft = AgentVersion.objects.create(
|
||
definition=definition,
|
||
version=latest + 1,
|
||
status="draft",
|
||
model_deployment=source.model_deployment,
|
||
system_prompt=source.system_prompt,
|
||
input_schema=deepcopy(source.input_schema),
|
||
example_input=deepcopy(source.example_input),
|
||
allowed_actions=deepcopy(source.allowed_actions),
|
||
temperature_override=source.temperature_override,
|
||
max_output_tokens_override=source.max_output_tokens_override,
|
||
created_by=user,
|
||
)
|
||
AgentStepBinding.objects.bulk_create(
|
||
[
|
||
AgentStepBinding(
|
||
agent_version=draft,
|
||
code=step.code,
|
||
order=step.order,
|
||
skill_version=step.skill_version,
|
||
model_deployment=step.model_deployment,
|
||
input_mapping=deepcopy(step.input_mapping),
|
||
)
|
||
for step in source.steps.all()
|
||
]
|
||
)
|
||
return draft
|
||
|
||
|
||
def build_config_snapshot(version: AgentVersion) -> dict:
|
||
"""冻结一次运行所需的 Agent、模型、Skill、映射和策略配置。"""
|
||
|
||
deployment = version.model_deployment
|
||
return {
|
||
"agent": {
|
||
"id": version.definition_id,
|
||
"code": version.definition.code,
|
||
"version": version.version,
|
||
"status": version.status,
|
||
"allowed_actions": deepcopy(version.allowed_actions),
|
||
},
|
||
"model": {
|
||
"deployment_id": deployment.id,
|
||
"provider_name": deployment.provider.name,
|
||
"model_name": deployment.model_name,
|
||
"temperature": version.temperature_override
|
||
if version.temperature_override is not None
|
||
else deployment.default_temperature,
|
||
"max_output_tokens": version.max_output_tokens_override or deployment.max_output_tokens,
|
||
},
|
||
"steps": [
|
||
{
|
||
"code": step.code,
|
||
"order": step.order,
|
||
"skill_id": step.skill_version.definition_id,
|
||
"skill_code": step.skill_version.definition.code,
|
||
"skill_version": step.skill_version.version,
|
||
"input_mapping": step.input_mapping,
|
||
"model": {
|
||
"deployment_id": (
|
||
step.model_deployment_id or version.model_deployment_id
|
||
),
|
||
"inherited": step.model_deployment_id is None,
|
||
"provider_name": (
|
||
step.model_deployment.provider.name
|
||
if step.model_deployment_id
|
||
else deployment.provider.name
|
||
),
|
||
"model_name": (
|
||
step.model_deployment.model_name
|
||
if step.model_deployment_id
|
||
else deployment.model_name
|
||
),
|
||
"capability": (
|
||
step.model_deployment.capability
|
||
if step.model_deployment_id
|
||
else deployment.capability
|
||
),
|
||
},
|
||
}
|
||
for step in version.steps.select_related(
|
||
"skill_version__definition",
|
||
"model_deployment__provider",
|
||
)
|
||
],
|
||
}
|
||
|
||
|
||
def queue_agent_run(
|
||
version: AgentVersion,
|
||
input_payload: dict,
|
||
*,
|
||
created_by=None,
|
||
sandbox: bool = False,
|
||
config_snapshot_extra: dict | None = None,
|
||
defer_until_commit: bool = False,
|
||
) -> AgentRun:
|
||
"""创建运行及初始事件,并按事务边界决定何时投递 Celery 任务。"""
|
||
|
||
validate_agent_version(version, publishing=not sandbox)
|
||
validate_payload(version.input_schema, input_payload, "Agent 输入")
|
||
config_snapshot = build_config_snapshot(version)
|
||
if config_snapshot_extra:
|
||
config_snapshot.update(deepcopy(config_snapshot_extra))
|
||
run = AgentRun.objects.create(
|
||
definition=version.definition,
|
||
agent_version=version,
|
||
sandbox=sandbox,
|
||
input_payload=deepcopy(input_payload),
|
||
config_snapshot=config_snapshot,
|
||
total_steps=version.steps.count(),
|
||
created_by=created_by,
|
||
)
|
||
append_run_event(run, "queued", {"message": "Agent 运行已进入处理队列"})
|
||
|
||
def dispatch() -> None:
|
||
from apps.agents.tasks import execute_agent_run
|
||
|
||
try:
|
||
result = execute_agent_run.delay(run.id)
|
||
except Exception as error:
|
||
run.status = "failed"
|
||
run.progress_stage = "failed"
|
||
run.progress_message = "Agent 运行入队失败"
|
||
run.error_code = "queue_failed"
|
||
run.error_message = str(error)
|
||
run.finished_at = timezone.now()
|
||
run.save(
|
||
update_fields=[
|
||
"status",
|
||
"progress_stage",
|
||
"progress_message",
|
||
"error_code",
|
||
"error_message",
|
||
"finished_at",
|
||
]
|
||
)
|
||
append_run_event(
|
||
run,
|
||
"failed",
|
||
{"error_code": run.error_code, "error_message": run.error_message},
|
||
)
|
||
return
|
||
run.task_id = result.id or ""
|
||
run.save(update_fields=["task_id"])
|
||
|
||
if defer_until_commit:
|
||
transaction.on_commit(dispatch)
|
||
else:
|
||
dispatch()
|
||
run.refresh_from_db()
|
||
return run
|
||
|
||
|
||
def _parse_json_output(raw_output: str) -> Any:
|
||
value = raw_output.strip()
|
||
if value.startswith("```"):
|
||
value = re.sub(r"^```(?:json)?\s*", "", value)
|
||
value = re.sub(r"\s*```$", "", value)
|
||
try:
|
||
return json.loads(value)
|
||
except json.JSONDecodeError as error:
|
||
raise SkillValidationError(f"模型输出不是合法 JSON: {error.msg}") from error
|
||
|
||
|
||
def _empty_schema_value(schema: dict) -> Any:
|
||
value_type = schema.get("type")
|
||
if value_type == "string" and (not schema.get("enum") or "" in schema["enum"]):
|
||
return ""
|
||
if value_type == "array":
|
||
return []
|
||
if value_type == "object":
|
||
return {}
|
||
return None
|
||
|
||
|
||
def _complete_object_shape(value: dict, schema: dict) -> dict:
|
||
completed = dict(value)
|
||
properties = schema.get("properties", {})
|
||
for code in schema.get("required", []):
|
||
if code in completed:
|
||
continue
|
||
empty_value = _empty_schema_value(properties.get(code, {}))
|
||
if empty_value is not None:
|
||
completed[code] = empty_value
|
||
return completed
|
||
|
||
|
||
def _normalize_nested_output(output: Any, output_schema: dict) -> Any:
|
||
"""Repair common flattened model output without another model round-trip."""
|
||
if not isinstance(output, dict):
|
||
return output
|
||
properties = output_schema.get("properties", {})
|
||
required = set(output_schema.get("required", []))
|
||
|
||
if {"facts", "evidence", "missing_fields"}.issubset(required):
|
||
fact_schema = properties.get("facts", {})
|
||
fact_codes = set(fact_schema.get("properties", {}))
|
||
nested_facts = output.get("facts")
|
||
if isinstance(nested_facts, dict):
|
||
facts = _complete_object_shape(nested_facts, fact_schema)
|
||
elif fact_codes.intersection(output):
|
||
facts = _complete_object_shape(
|
||
{code: output[code] for code in fact_codes if code in output},
|
||
fact_schema,
|
||
)
|
||
else:
|
||
facts = None
|
||
if facts is not None:
|
||
evidence = _complete_object_shape(
|
||
output.get("evidence", {}) if isinstance(output.get("evidence"), dict) else {},
|
||
properties.get("evidence", {}),
|
||
)
|
||
missing = list(output.get("missing_fields", []))
|
||
for code, value in facts.items():
|
||
if not value and code not in missing:
|
||
missing.append(code)
|
||
return {"facts": facts, "evidence": evidence, "missing_fields": missing}
|
||
|
||
if {"fields", "missing_fields", "questions"}.issubset(required):
|
||
field_schema = properties.get("fields", {})
|
||
field_codes = set(field_schema.get("properties", {}))
|
||
nested_fields = output.get("fields")
|
||
if isinstance(nested_fields, dict):
|
||
fields = _complete_object_shape(nested_fields, field_schema)
|
||
normalized = dict(output)
|
||
elif field_codes.intersection(output):
|
||
fields = _complete_object_shape(
|
||
{code: output[code] for code in field_codes if code in output},
|
||
field_schema,
|
||
)
|
||
normalized = {
|
||
code: value
|
||
for code, value in output.items()
|
||
if code not in field_codes
|
||
}
|
||
else:
|
||
fields = None
|
||
if fields is not None:
|
||
questions = output.get("questions", output.get("follow_up_questions", []))
|
||
normalized.pop("follow_up_questions", None)
|
||
normalized.update(
|
||
{
|
||
"fields": fields,
|
||
"missing_fields": list(output.get("missing_fields", [])),
|
||
"questions": list(questions) if isinstance(questions, list) else [],
|
||
}
|
||
)
|
||
return normalized
|
||
return output
|
||
|
||
|
||
def _record_attempt(
|
||
step: AgentRunStep,
|
||
invocation: ModelInvocation,
|
||
attempt_type: str,
|
||
validation_error: str = "",
|
||
) -> AgentRunAttempt:
|
||
return AgentRunAttempt.objects.create(
|
||
step=step,
|
||
model_invocation=invocation,
|
||
attempt_type=attempt_type,
|
||
status=invocation.status,
|
||
raw_output=invocation.output_text,
|
||
validation_error=validation_error,
|
||
input_tokens=invocation.input_tokens or 0,
|
||
output_tokens=invocation.output_tokens or 0,
|
||
duration_ms=invocation.duration_ms,
|
||
)
|
||
|
||
|
||
def _invoke_for_step(
|
||
run: AgentRun,
|
||
step: AgentRunStep,
|
||
prompt: str,
|
||
attempt_type: str,
|
||
image_parts: list[dict] | None = None,
|
||
) -> ModelInvocation:
|
||
version = run.agent_version
|
||
deployment = step.model_deployment or version.model_deployment
|
||
messages = []
|
||
if version.system_prompt:
|
||
messages.append({"role": "system", "content": version.system_prompt})
|
||
user_content: str | list[dict] = prompt
|
||
step_image_parts = None
|
||
if image_parts and deployment.capability == "vision_chat":
|
||
if not deployment.provider.image_egress_approved:
|
||
raise AgentValidationError(
|
||
f"步骤 {step.code} 的模型渠道未授权图片出境"
|
||
)
|
||
step_image_parts = image_parts
|
||
if step_image_parts:
|
||
user_content = [{"type": "text", "text": prompt}, *step_image_parts]
|
||
messages.append({"role": "user", "content": user_content})
|
||
snapshot_messages = [
|
||
{"role": item["role"], "content": prompt if item["role"] == "user" else item["content"]}
|
||
for item in messages
|
||
]
|
||
invocation = ModelInvocation.objects.create(
|
||
deployment=deployment,
|
||
invocation_type="agent_step",
|
||
input_snapshot={
|
||
"messages": snapshot_messages,
|
||
"image_refs": (
|
||
run.input_payload.get("image_refs", []) if step_image_parts else []
|
||
),
|
||
"run_id": str(run.public_id),
|
||
"step": step.code,
|
||
},
|
||
created_by=run.created_by,
|
||
)
|
||
|
||
if attempt_type == "structure_repair":
|
||
_set_run_progress(run, "repairing", "正在修正结构化结果")
|
||
|
||
def mark_retry(retry_number: int, _error_code: str) -> None:
|
||
run.retry_count += 1
|
||
run.progress_stage = "retrying"
|
||
run.progress_message = f"模型响应暂时不稳定,正在进行第 {retry_number} 次重试"
|
||
run.save(update_fields=["retry_count", "progress_stage", "progress_message"])
|
||
|
||
execute_invocation(
|
||
invocation,
|
||
messages,
|
||
temperature=version.temperature_override,
|
||
max_output_tokens=version.max_output_tokens_override,
|
||
on_retry=mark_retry,
|
||
total_timeout_seconds=settings.AI_AGENT_MODEL_TIMEOUT_SECONDS,
|
||
)
|
||
if invocation.status == "succeeded":
|
||
_set_run_progress(run, "validating", "正在校验结构化结果")
|
||
_record_attempt(step, invocation, attempt_type)
|
||
if invocation.retry_count:
|
||
AgentRunAttempt.objects.create(
|
||
step=step,
|
||
model_invocation=invocation,
|
||
attempt_type="transient_retry",
|
||
status=invocation.status,
|
||
raw_output=invocation.output_text,
|
||
input_tokens=0,
|
||
output_tokens=0,
|
||
duration_ms=0,
|
||
)
|
||
return invocation
|
||
|
||
|
||
def _repair_output(
|
||
run: AgentRun,
|
||
step: AgentRunStep,
|
||
raw_output: str,
|
||
validation_error: str,
|
||
output_schema: dict,
|
||
) -> ModelInvocation:
|
||
prompt = (
|
||
"请只输出修正后的 JSON,不要解释。\n"
|
||
f"输出 Schema:{json.dumps(output_schema, ensure_ascii=False)}\n"
|
||
f"校验错误:{validation_error}\n"
|
||
f"原始输出:{raw_output}"
|
||
)
|
||
invocation = _invoke_for_step(run, step, prompt, "structure_repair")
|
||
attempt = step.attempts.order_by("-created_at").first()
|
||
if attempt:
|
||
attempt.validation_error = validation_error
|
||
attempt.save(update_fields=["validation_error"])
|
||
return invocation
|
||
|
||
|
||
def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) -> AgentRun:
|
||
"""按冻结配置顺序执行所有步骤,记录尝试、产物、进度和终态事件。"""
|
||
|
||
run = AgentRun.objects.select_related(
|
||
"definition",
|
||
"agent_version__model_deployment__provider",
|
||
"created_by",
|
||
).get(id=run_id)
|
||
started = monotonic()
|
||
run.status = "running"
|
||
run.started_at = timezone.now()
|
||
run.progress_stage = "starting"
|
||
run.progress_message = "正在准备 Agent 运行"
|
||
run.save(update_fields=["status", "started_at", "progress_stage", "progress_message"])
|
||
append_run_event(run, "starting", {"message": run.progress_message})
|
||
step_outputs: dict[str, dict] = {}
|
||
try:
|
||
bindings = list(
|
||
run.agent_version.steps.select_related(
|
||
"skill_version__definition",
|
||
"model_deployment__provider",
|
||
"agent_version__model_deployment__provider",
|
||
)
|
||
)
|
||
for binding in bindings:
|
||
run.refresh_from_db(fields=["cancellation_requested_at"])
|
||
if run.cancellation_requested_at:
|
||
run.status = "cancelled"
|
||
_set_run_progress(run, "cancelled", "运行已取消")
|
||
break
|
||
run.current_step = binding.code
|
||
run.save(update_fields=["current_step"])
|
||
progress_stage, progress_message = _step_progress(
|
||
binding.code,
|
||
binding.skill_version.definition.name,
|
||
)
|
||
_set_run_progress(run, progress_stage, progress_message)
|
||
step = AgentRunStep.objects.create(
|
||
run=run,
|
||
code=binding.code,
|
||
name=binding.skill_version.definition.name,
|
||
order=binding.order,
|
||
skill_version=binding.skill_version,
|
||
model_deployment=(
|
||
binding.model_deployment or run.agent_version.model_deployment
|
||
),
|
||
status="running",
|
||
started_at=timezone.now(),
|
||
)
|
||
step_started = monotonic()
|
||
try:
|
||
step_input = map_step_input(binding, run.input_payload, step_outputs)
|
||
validate_payload(
|
||
binding.skill_version.input_schema,
|
||
step_input,
|
||
f"步骤 {binding.code} 输入",
|
||
)
|
||
step.input_payload = step_input
|
||
step.save(update_fields=["input_payload"])
|
||
prompt = render_prompt(binding.skill_version.prompt_template, step_input)
|
||
invocation = _invoke_for_step(run, step, prompt, "initial", image_parts)
|
||
if invocation.status != "succeeded":
|
||
raise AgentValidationError(invocation.error_message or "模型调用失败")
|
||
try:
|
||
output = _parse_json_output(invocation.output_text)
|
||
output = _normalize_nested_output(
|
||
output,
|
||
binding.skill_version.output_schema,
|
||
)
|
||
validate_payload(binding.skill_version.output_schema, output, "模型输出")
|
||
except SkillValidationError as error:
|
||
repair = _repair_output(
|
||
run,
|
||
step,
|
||
invocation.output_text,
|
||
str(error),
|
||
binding.skill_version.output_schema,
|
||
)
|
||
if repair.status != "succeeded":
|
||
raise AgentValidationError(
|
||
repair.error_message or "结构修复失败"
|
||
) from error
|
||
output = _parse_json_output(repair.output_text)
|
||
output = _normalize_nested_output(
|
||
output,
|
||
binding.skill_version.output_schema,
|
||
)
|
||
validate_payload(binding.skill_version.output_schema, output, "修复输出")
|
||
step.output_payload = output
|
||
step.status = "succeeded"
|
||
step_outputs[binding.code] = {"output": output}
|
||
except (AgentValidationError, SkillValidationError) as error:
|
||
step.status = "failed"
|
||
step.error_code = "step_failed"
|
||
step.error_message = str(error)
|
||
run.status = "failed"
|
||
run.error_code = "step_failed"
|
||
run.error_message = f"步骤 {binding.code} 失败: {error}"
|
||
_set_run_progress(run, "failed", "Agent 运行失败")
|
||
finally:
|
||
attempts = step.attempts.all()
|
||
step.input_tokens = sum(item.input_tokens for item in attempts)
|
||
step.output_tokens = sum(item.output_tokens for item in attempts)
|
||
step.duration_ms = max(int((monotonic() - step_started) * 1000), 0)
|
||
step.finished_at = timezone.now()
|
||
step.save()
|
||
if run.status == "failed":
|
||
break
|
||
run.refresh_from_db(fields=["cancellation_requested_at"])
|
||
if run.cancellation_requested_at:
|
||
run.status = "cancelled"
|
||
_set_run_progress(run, "cancelled", "运行已取消")
|
||
break
|
||
if run.status == "running":
|
||
run.final_output = step.output_payload if bindings else None
|
||
try:
|
||
from apps.tickets.support_analysis import validate_support_analysis_output
|
||
|
||
validate_support_analysis_output(run, run.final_output)
|
||
from apps.knowledge.distillation import (
|
||
validate_knowledge_distillation_output,
|
||
)
|
||
|
||
validate_knowledge_distillation_output(run, run.final_output)
|
||
except ValueError as error:
|
||
run.status = "failed"
|
||
run.error_code = (
|
||
"untrusted_support_output"
|
||
if run.definition.code == "support-analysis"
|
||
else "untrusted_knowledge_distillation_output"
|
||
)
|
||
run.error_message = str(error)
|
||
_set_run_progress(run, "failed", "Agent 结果未通过业务信任校验")
|
||
else:
|
||
run.status = "succeeded"
|
||
_set_run_progress(run, "completed", "草稿生成完成")
|
||
except Exception as error:
|
||
run.status = "failed"
|
||
run.error_code = "runtime_error"
|
||
run.error_message = "Agent 运行发生未预期错误"
|
||
_set_run_progress(run, "failed", "Agent 运行失败")
|
||
OperationAuditLog.objects.create(
|
||
user=run.created_by,
|
||
module="agents",
|
||
action="runtime_error",
|
||
target_type="agent_run",
|
||
target_id=str(run.public_id),
|
||
request_snapshot={"error_type": type(error).__name__},
|
||
result="failed",
|
||
)
|
||
run.input_tokens = sum(item.input_tokens for item in run.steps.all())
|
||
run.output_tokens = sum(item.output_tokens for item in run.steps.all())
|
||
run.duration_ms = max(int((monotonic() - started) * 1000), 0)
|
||
run.finished_at = timezone.now()
|
||
run.save()
|
||
if run.definition.code == "support-analysis":
|
||
from apps.workitems.services import sync_support_analysis_work_item
|
||
|
||
if run.status == "succeeded":
|
||
try:
|
||
from apps.tickets.support_analysis import process_support_analysis_result
|
||
|
||
process_support_analysis_result(run)
|
||
except Exception as error:
|
||
OperationAuditLog.objects.create(
|
||
user=run.created_by,
|
||
module="tickets",
|
||
action="support_post_processing_failed",
|
||
target_type="agent_run",
|
||
target_id=str(run.public_id),
|
||
request_snapshot={"error_type": type(error).__name__},
|
||
result="failed",
|
||
)
|
||
sync_support_analysis_work_item(
|
||
run,
|
||
error_code="post_processing_failed",
|
||
error_message="支持分析结果后处理失败,请人工接管",
|
||
)
|
||
else:
|
||
sync_support_analysis_work_item(run)
|
||
else:
|
||
sync_support_analysis_work_item(run)
|
||
if run.definition.code == "knowledge-distillation":
|
||
from apps.knowledge.models import TicketKnowledgeDistillation
|
||
from apps.workitems.services import sync_knowledge_distillation_work_item
|
||
|
||
try:
|
||
distillation = run.ticket_knowledge_distillation
|
||
except TicketKnowledgeDistillation.DoesNotExist:
|
||
distillation = None
|
||
if distillation is not None:
|
||
if run.status == "succeeded":
|
||
try:
|
||
from apps.knowledge.distillation import (
|
||
process_knowledge_distillation_result,
|
||
)
|
||
|
||
process_knowledge_distillation_result(run)
|
||
except Exception as error:
|
||
OperationAuditLog.objects.create(
|
||
user=run.created_by,
|
||
module="knowledge",
|
||
action="knowledge_distillation_post_processing_failed",
|
||
target_type="agent_run",
|
||
target_id=str(run.public_id),
|
||
request_snapshot={"error_type": type(error).__name__},
|
||
result="failed",
|
||
)
|
||
sync_knowledge_distillation_work_item(
|
||
distillation.ticket,
|
||
run=run,
|
||
error_code="post_processing_failed",
|
||
error_message="知识蒸馏结果后处理失败,请人工接管",
|
||
)
|
||
else:
|
||
sync_knowledge_distillation_work_item(
|
||
distillation.ticket,
|
||
run=run,
|
||
)
|
||
else:
|
||
sync_knowledge_distillation_work_item(
|
||
distillation.ticket,
|
||
run=run,
|
||
)
|
||
OperationAuditLog.objects.create(
|
||
user=run.created_by,
|
||
module="agents",
|
||
action="run_finished",
|
||
target_type="agent_run",
|
||
target_id=str(run.public_id),
|
||
request_snapshot={"status": run.status, "duration_ms": run.duration_ms},
|
||
result="success" if run.status == "succeeded" else run.status,
|
||
)
|
||
return run
|
||
|
||
|
||
def replace_agent_steps(version: AgentVersion, steps: list[dict]) -> None:
|
||
if len(steps) > 10:
|
||
raise AgentValidationError("Agent 最多允许 10 个步骤")
|
||
with transaction.atomic():
|
||
version.steps.all().delete()
|
||
AgentStepBinding.objects.bulk_create(
|
||
[
|
||
AgentStepBinding(
|
||
agent_version=version,
|
||
code=item["code"],
|
||
order=index + 1,
|
||
skill_version_id=item["skill_version_id"],
|
||
model_deployment_id=item.get("model_deployment_id"),
|
||
input_mapping=item.get("input_mapping", {}),
|
||
)
|
||
for index, item in enumerate(steps)
|
||
]
|
||
)
|
||
validate_agent_version(version)
|