feat: add ordered agent runtime
This commit is contained in:
1
backend/apps/agents/__init__.py
Normal file
1
backend/apps/agents/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
380
backend/apps/agents/api.py
Normal file
380
backend/apps/agents/api.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from celery.result import AsyncResult
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.agents.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentStepBinding,
|
||||
AgentVersion,
|
||||
)
|
||||
from apps.agents.schemas import (
|
||||
AgentCreateIn,
|
||||
AgentDefinitionOut,
|
||||
AgentDefinitionUpdateIn,
|
||||
AgentDetailOut,
|
||||
AgentDraftUpdateIn,
|
||||
AgentRunDetailOut,
|
||||
AgentRunIn,
|
||||
AgentRunSummaryOut,
|
||||
AgentVersionOut,
|
||||
)
|
||||
from apps.agents.services import (
|
||||
AgentValidationError,
|
||||
build_config_snapshot,
|
||||
ensure_agent_draft,
|
||||
replace_agent_steps,
|
||||
validate_agent_version,
|
||||
)
|
||||
from apps.agents.tasks import execute_agent_run
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.skills.services import SkillValidationError, validate_payload
|
||||
|
||||
router = Router(tags=["agents"])
|
||||
runs_router = Router(tags=["agent-runs"])
|
||||
|
||||
|
||||
def serialize_step(step: AgentStepBinding) -> dict:
|
||||
return {
|
||||
"id": step.id,
|
||||
"code": step.code,
|
||||
"order": step.order,
|
||||
"skill_version_id": step.skill_version_id,
|
||||
"skill_name": step.skill_version.definition.name,
|
||||
"skill_version": step.skill_version.version,
|
||||
"skill_status": step.skill_version.status,
|
||||
"input_mapping": step.input_mapping,
|
||||
}
|
||||
|
||||
|
||||
def serialize_version(version: AgentVersion) -> dict:
|
||||
return {
|
||||
"id": version.id,
|
||||
"version": version.version,
|
||||
"status": version.status,
|
||||
"model_deployment_id": version.model_deployment_id,
|
||||
"model_display_name": version.model_deployment.display_name,
|
||||
"system_prompt": version.system_prompt,
|
||||
"input_schema": version.input_schema,
|
||||
"example_input": version.example_input,
|
||||
"temperature_override": version.temperature_override,
|
||||
"max_output_tokens_override": version.max_output_tokens_override,
|
||||
"steps": [serialize_step(step) for step in version.steps.all()],
|
||||
"created_at": version.created_at,
|
||||
"updated_at": version.updated_at,
|
||||
"published_at": version.published_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_definition(definition: AgentDefinition, detailed: bool = False) -> dict:
|
||||
draft = definition.versions.filter(status="draft").first()
|
||||
payload = {
|
||||
"id": definition.id,
|
||||
"code": definition.code,
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"active_version": definition.active_version.version
|
||||
if definition.active_version_id
|
||||
else None,
|
||||
"draft_version": draft.version if draft else None,
|
||||
"updated_at": definition.updated_at,
|
||||
}
|
||||
if detailed:
|
||||
payload["versions"] = [serialize_version(version) for version in definition.versions.all()]
|
||||
return payload
|
||||
|
||||
|
||||
def serialize_run(run: AgentRun, detailed: bool = False) -> dict:
|
||||
payload = {
|
||||
"public_id": run.public_id,
|
||||
"agent_id": run.definition_id,
|
||||
"agent_name": run.definition.name,
|
||||
"agent_version": run.agent_version.version,
|
||||
"status": run.status,
|
||||
"sandbox": run.sandbox,
|
||||
"current_step": run.current_step,
|
||||
"total_steps": run.total_steps,
|
||||
"input_tokens": run.input_tokens,
|
||||
"output_tokens": run.output_tokens,
|
||||
"duration_ms": run.duration_ms,
|
||||
"error_code": run.error_code,
|
||||
"error_message": run.error_message,
|
||||
"created_at": run.created_at,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
}
|
||||
if detailed:
|
||||
payload.update(
|
||||
{
|
||||
"input_payload": run.input_payload,
|
||||
"final_output": run.final_output,
|
||||
"config_snapshot": run.config_snapshot,
|
||||
"cancellation_requested_at": run.cancellation_requested_at,
|
||||
"steps": [
|
||||
{
|
||||
"id": step.id,
|
||||
"code": step.code,
|
||||
"name": step.name,
|
||||
"order": step.order,
|
||||
"status": step.status,
|
||||
"input_payload": step.input_payload,
|
||||
"output_payload": step.output_payload,
|
||||
"input_tokens": step.input_tokens,
|
||||
"output_tokens": step.output_tokens,
|
||||
"duration_ms": step.duration_ms,
|
||||
"error_code": step.error_code,
|
||||
"error_message": step.error_message,
|
||||
"attempts": list(step.attempts.all()),
|
||||
}
|
||||
for step in run.steps.all()
|
||||
],
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def audit(user, action: str, target_type: str, target_id: str, snapshot: dict) -> None:
|
||||
OperationAuditLog.objects.create(
|
||||
user=user,
|
||||
module="agents",
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
request_snapshot=snapshot,
|
||||
result="success",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response=list[AgentDefinitionOut])
|
||||
def list_agents(request):
|
||||
require_permission(request, "agent.manage")
|
||||
queryset = AgentDefinition.objects.select_related("active_version").prefetch_related("versions")
|
||||
return [serialize_definition(item) for item in queryset]
|
||||
|
||||
|
||||
@router.post("", response=AgentDetailOut)
|
||||
def create_agent(request, payload: AgentCreateIn):
|
||||
require_permission(request, "agent.manage")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
definition = AgentDefinition.objects.create(
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created_by=request.user,
|
||||
)
|
||||
version = AgentVersion.objects.create(
|
||||
definition=definition,
|
||||
version=1,
|
||||
model_deployment_id=payload.model_deployment_id,
|
||||
system_prompt=payload.system_prompt,
|
||||
input_schema=payload.input_schema,
|
||||
example_input=payload.example_input,
|
||||
temperature_override=payload.temperature_override,
|
||||
max_output_tokens_override=payload.max_output_tokens_override,
|
||||
created_by=request.user,
|
||||
)
|
||||
replace_agent_steps(version, [item.dict() for item in payload.steps])
|
||||
except (AgentValidationError, SkillValidationError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
except IntegrityError as error:
|
||||
raise HttpError(400, "Agent code or step configuration already exists") from error
|
||||
audit(request.user, "create", "agent", str(definition.id), {"code": definition.code})
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=definition.id)
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.get("/{agent_id}", response=AgentDetailOut)
|
||||
def get_agent(request, agent_id: int):
|
||||
require_permission(request, "agent.manage")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.patch("/{agent_id}", response=AgentDetailOut)
|
||||
def update_agent(request, agent_id: int, payload: AgentDefinitionUpdateIn):
|
||||
require_permission(request, "agent.manage")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
values = payload.dict(exclude_unset=True)
|
||||
for field, value in values.items():
|
||||
setattr(definition, field, value)
|
||||
definition.save()
|
||||
audit(request.user, "update", "agent", str(definition.id), {"fields": sorted(values)})
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.post("/{agent_id}/draft", response=AgentVersionOut)
|
||||
def create_agent_draft(request, agent_id: int):
|
||||
require_permission(request, "agent.manage")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
try:
|
||||
return serialize_version(ensure_agent_draft(definition, request.user))
|
||||
except AgentValidationError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
|
||||
|
||||
@router.get("/{agent_id}/draft", response=AgentVersionOut)
|
||||
def get_agent_draft(request, agent_id: int):
|
||||
require_permission(request, "agent.manage")
|
||||
version = AgentVersion.objects.select_related("model_deployment").get(
|
||||
definition_id=agent_id,
|
||||
status="draft",
|
||||
)
|
||||
return serialize_version(version)
|
||||
|
||||
|
||||
@router.patch("/{agent_id}/draft", response=AgentVersionOut)
|
||||
def update_agent_draft(request, agent_id: int, payload: AgentDraftUpdateIn):
|
||||
require_permission(request, "agent.manage")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
try:
|
||||
version = ensure_agent_draft(definition, request.user)
|
||||
values = payload.dict(exclude_unset=True)
|
||||
steps = values.pop("steps", None)
|
||||
for field, value in values.items():
|
||||
setattr(version, field, value)
|
||||
version.save()
|
||||
if steps is not None:
|
||||
replace_agent_steps(version, steps)
|
||||
else:
|
||||
validate_agent_version(version)
|
||||
except (AgentValidationError, SkillValidationError, IntegrityError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
audit(
|
||||
request.user,
|
||||
"update_draft",
|
||||
"agent",
|
||||
str(definition.id),
|
||||
{"version": version.version},
|
||||
)
|
||||
return serialize_version(version)
|
||||
|
||||
|
||||
@router.get("/{agent_id}/versions", response=list[AgentVersionOut])
|
||||
def list_agent_versions(request, agent_id: int):
|
||||
require_permission(request, "agent.manage")
|
||||
versions = AgentVersion.objects.select_related("model_deployment").filter(
|
||||
definition_id=agent_id
|
||||
)
|
||||
return [serialize_version(version) for version in versions]
|
||||
|
||||
|
||||
@router.post("/{agent_id}/publish", response=AgentVersionOut)
|
||||
def publish_agent(request, agent_id: int):
|
||||
require_permission(request, "agent.manage")
|
||||
with transaction.atomic():
|
||||
definition = AgentDefinition.objects.select_for_update().get(id=agent_id)
|
||||
version = AgentVersion.objects.select_related("model_deployment").get(
|
||||
definition=definition,
|
||||
status="draft",
|
||||
)
|
||||
try:
|
||||
validate_agent_version(version, publishing=True)
|
||||
except (AgentValidationError, SkillValidationError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
version.status = "published"
|
||||
version.published_by = request.user
|
||||
version.published_at = timezone.now()
|
||||
version.save(update_fields=["status", "published_by", "published_at", "updated_at"])
|
||||
definition.active_version = version
|
||||
definition.save(update_fields=["active_version", "updated_at"])
|
||||
audit(request.user, "publish", "agent", str(definition.id), {"version": version.version})
|
||||
return serialize_version(version)
|
||||
|
||||
|
||||
@router.post("/{agent_id}/rollback/{version}", response=AgentDetailOut)
|
||||
def rollback_agent(request, agent_id: int, version: int):
|
||||
require_permission(request, "agent.manage")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
target = AgentVersion.objects.get(
|
||||
definition=definition,
|
||||
version=version,
|
||||
status="published",
|
||||
)
|
||||
definition.active_version = target
|
||||
definition.save(update_fields=["active_version", "updated_at"])
|
||||
audit(request.user, "rollback", "agent", str(definition.id), {"version": version})
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.post("/{agent_id}/runs", response=AgentRunDetailOut)
|
||||
def start_agent_run(request, agent_id: int, payload: AgentRunIn):
|
||||
require_permission(request, "agent.use")
|
||||
definition = AgentDefinition.objects.select_related("active_version").get(id=agent_id)
|
||||
if str(payload.version) == "draft":
|
||||
version = AgentVersion.objects.get(definition=definition, status="draft")
|
||||
sandbox = True
|
||||
else:
|
||||
version = AgentVersion.objects.get(
|
||||
definition=definition,
|
||||
version=int(payload.version),
|
||||
status="published",
|
||||
)
|
||||
sandbox = False
|
||||
try:
|
||||
validate_agent_version(version, publishing=not sandbox)
|
||||
validate_payload(version.input_schema, payload.input, "Agent 输入")
|
||||
except (AgentValidationError, SkillValidationError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
run = AgentRun.objects.create(
|
||||
definition=definition,
|
||||
agent_version=version,
|
||||
sandbox=sandbox,
|
||||
input_payload=payload.input,
|
||||
config_snapshot=build_config_snapshot(version),
|
||||
total_steps=version.steps.count(),
|
||||
created_by=request.user,
|
||||
)
|
||||
result = execute_agent_run.delay(run.id)
|
||||
run.refresh_from_db()
|
||||
if not run.task_id:
|
||||
run.task_id = result.id or ""
|
||||
run.save(update_fields=["task_id"])
|
||||
audit(
|
||||
request.user,
|
||||
"run_started",
|
||||
"agent_run",
|
||||
str(run.public_id),
|
||||
{"agent_id": definition.id, "version": version.version, "sandbox": sandbox},
|
||||
)
|
||||
return serialize_run(run, detailed=True)
|
||||
|
||||
|
||||
@runs_router.get("", response=list[AgentRunSummaryOut])
|
||||
def list_agent_runs(request, status: str | None = None, agent_id: int | None = None):
|
||||
require_permission(request, "agent.run.view")
|
||||
queryset = AgentRun.objects.select_related("definition", "agent_version")
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
if agent_id:
|
||||
queryset = queryset.filter(definition_id=agent_id)
|
||||
return [serialize_run(run) for run in queryset[:100]]
|
||||
|
||||
|
||||
@runs_router.get("/{public_id}", response=AgentRunDetailOut)
|
||||
def get_agent_run(request, public_id: str):
|
||||
require_permission(request, "agent.run.view")
|
||||
run = AgentRun.objects.select_related("definition", "agent_version").get(public_id=public_id)
|
||||
return serialize_run(run, detailed=True)
|
||||
|
||||
|
||||
@runs_router.post("/{public_id}/cancel", response=AgentRunDetailOut)
|
||||
def cancel_agent_run(request, public_id: str):
|
||||
require_permission(request, "agent.use")
|
||||
run = AgentRun.objects.select_related("definition", "agent_version").get(public_id=public_id)
|
||||
if run.status not in {"queued", "running"}:
|
||||
raise HttpError(400, "Only queued or running runs can be cancelled")
|
||||
run.cancellation_requested_at = timezone.now()
|
||||
update_fields = ["cancellation_requested_at"]
|
||||
if run.status == "queued":
|
||||
run.status = "cancelled"
|
||||
run.finished_at = timezone.now()
|
||||
update_fields.extend(["status", "finished_at"])
|
||||
run.save(update_fields=update_fields)
|
||||
if run.task_id:
|
||||
AsyncResult(run.task_id).revoke(terminate=False)
|
||||
audit(request.user, "cancel", "agent_run", str(run.public_id), {"status": run.status})
|
||||
return serialize_run(run, detailed=True)
|
||||
6
backend/apps/agents/apps.py
Normal file
6
backend/apps/agents/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AgentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.agents"
|
||||
1
backend/apps/agents/management/__init__.py
Normal file
1
backend/apps/agents/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/apps/agents/management/commands/__init__.py
Normal file
1
backend/apps/agents/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
194
backend/apps/agents/management/commands/seed_ai_demo.py
Normal file
194
backend/apps/agents/management/commands/seed_ai_demo.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
|
||||
from apps.modelhub.models import ModelDeployment
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
|
||||
SKILLS = [
|
||||
{
|
||||
"code": "issue-structure",
|
||||
"name": "问题结构化",
|
||||
"description": "将技术支持问题整理为统一结构。",
|
||||
"prompt": (
|
||||
"请分析技术支持问题:{{ input.problem }},环境:{{ input.environment }}。只输出 JSON。"
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"problem": {"type": "string"},
|
||||
"environment": {"type": "string"},
|
||||
},
|
||||
"required": ["problem", "environment"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"symptoms": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["summary", "symptoms"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "troubleshooting",
|
||||
"name": "排查建议",
|
||||
"description": "基于结构化问题生成排查步骤。",
|
||||
"prompt": (
|
||||
"根据问题摘要 {{ input.summary }} 和现象 {{ input.symptoms }} "
|
||||
"生成排查建议,只输出 JSON。"
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"symptoms": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["summary", "symptoms"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"likely_cause": {"type": "string"},
|
||||
"steps": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["likely_cause", "steps"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "customer-reply",
|
||||
"name": "客户回复草稿",
|
||||
"description": "将排查建议整理为客户可读回复。",
|
||||
"prompt": (
|
||||
"原因:{{ input.likely_cause }};步骤:{{ input.steps }}。生成客户回复,只输出 JSON。"
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"likely_cause": {"type": "string"},
|
||||
"steps": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["likely_cause", "steps"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"reply": {"type": "string"}},
|
||||
"required": ["reply"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed the technical support analysis Agent and its published Skills."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--deployment-id", type=int)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
deployment_id = options.get("deployment_id")
|
||||
deployments = ModelDeployment.objects.filter(enabled=True, provider__enabled=True)
|
||||
deployment = (
|
||||
deployments.filter(id=deployment_id).first() if deployment_id else deployments.first()
|
||||
)
|
||||
if deployment is None:
|
||||
raise CommandError(
|
||||
"Configure an enabled model deployment before seeding the demo Agent."
|
||||
)
|
||||
|
||||
versions = {}
|
||||
for spec in SKILLS:
|
||||
definition, _ = SkillDefinition.objects.get_or_create(
|
||||
code=spec["code"],
|
||||
defaults={"name": spec["name"], "description": spec["description"]},
|
||||
)
|
||||
version = definition.versions.filter(version=1).first()
|
||||
if version is None:
|
||||
version = SkillVersion.objects.create(
|
||||
definition=definition,
|
||||
version=1,
|
||||
status="published",
|
||||
prompt_template=spec["prompt"],
|
||||
input_schema=spec["input_schema"],
|
||||
output_schema=spec["output_schema"],
|
||||
example_input={},
|
||||
published_at=timezone.now(),
|
||||
)
|
||||
definition.active_version = version
|
||||
definition.save(update_fields=["active_version", "updated_at"])
|
||||
versions[spec["code"]] = version
|
||||
|
||||
definition, _ = AgentDefinition.objects.get_or_create(
|
||||
code="support-analysis",
|
||||
defaults={
|
||||
"name": "技术支持分析 Agent",
|
||||
"description": "结构化问题、生成排查建议和客户回复草稿。",
|
||||
},
|
||||
)
|
||||
draft = definition.versions.filter(status="draft").first()
|
||||
if draft is None:
|
||||
next_version = (
|
||||
definition.versions.order_by("-version").values_list("version", flat=True).first()
|
||||
or 0
|
||||
) + 1
|
||||
draft = AgentVersion.objects.create(
|
||||
definition=definition,
|
||||
version=next_version,
|
||||
status="draft",
|
||||
model_deployment=deployment,
|
||||
system_prompt="你是企业技术支持助手。输出必须严格符合当前步骤要求的 JSON。",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"problem": {"type": "string"},
|
||||
"environment": {"type": "string"},
|
||||
},
|
||||
"required": ["problem", "environment"],
|
||||
},
|
||||
example_input={
|
||||
"problem": "采集任务长时间没有更新",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
)
|
||||
AgentStepBinding.objects.bulk_create(
|
||||
[
|
||||
AgentStepBinding(
|
||||
agent_version=draft,
|
||||
code="structure",
|
||||
order=1,
|
||||
skill_version=versions["issue-structure"],
|
||||
input_mapping={
|
||||
"problem": "$input.problem",
|
||||
"environment": "$input.environment",
|
||||
},
|
||||
),
|
||||
AgentStepBinding(
|
||||
agent_version=draft,
|
||||
code="troubleshooting",
|
||||
order=2,
|
||||
skill_version=versions["troubleshooting"],
|
||||
input_mapping={
|
||||
"summary": "$steps.structure.output.summary",
|
||||
"symptoms": "$steps.structure.output.symptoms",
|
||||
},
|
||||
),
|
||||
AgentStepBinding(
|
||||
agent_version=draft,
|
||||
code="reply",
|
||||
order=3,
|
||||
skill_version=versions["customer-reply"],
|
||||
input_mapping={
|
||||
"likely_cause": "$steps.troubleshooting.output.likely_cause",
|
||||
"steps": "$steps.troubleshooting.output.steps",
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Seeded Agent {definition.code} draft v{draft.version} "
|
||||
f"with deployment {deployment.id}."
|
||||
)
|
||||
)
|
||||
339
backend/apps/agents/migrations/0001_initial.py
Normal file
339
backend/apps/agents/migrations/0001_initial.py
Normal file
@@ -0,0 +1,339 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-14 05:54
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("modelhub", "0001_initial"),
|
||||
("skills", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AgentDefinition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("code", models.SlugField(max_length=80, unique=True)),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_agents",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AgentRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("sandbox", models.BooleanField(default=False)),
|
||||
("status", models.CharField(default="queued", max_length=24)),
|
||||
("input_payload", models.JSONField(default=dict)),
|
||||
("final_output", models.JSONField(blank=True, null=True)),
|
||||
("config_snapshot", models.JSONField(default=dict)),
|
||||
("current_step", models.CharField(blank=True, max_length=80)),
|
||||
("total_steps", models.PositiveIntegerField(default=0)),
|
||||
("input_tokens", models.PositiveIntegerField(default=0)),
|
||||
("output_tokens", models.PositiveIntegerField(default=0)),
|
||||
("duration_ms", models.PositiveIntegerField(default=0)),
|
||||
("task_id", models.CharField(blank=True, max_length=120)),
|
||||
("cancellation_requested_at", models.DateTimeField(blank=True, null=True)),
|
||||
("error_code", models.CharField(blank=True, max_length=80)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="agent_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"definition",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="runs",
|
||||
to="agents.agentdefinition",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AgentRunStep",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("code", models.CharField(max_length=80)),
|
||||
("name", models.CharField(max_length=120)),
|
||||
("order", models.PositiveIntegerField()),
|
||||
("status", models.CharField(default="queued", max_length=24)),
|
||||
("input_payload", models.JSONField(default=dict)),
|
||||
("output_payload", models.JSONField(blank=True, null=True)),
|
||||
("input_tokens", models.PositiveIntegerField(default=0)),
|
||||
("output_tokens", models.PositiveIntegerField(default=0)),
|
||||
("duration_ms", models.PositiveIntegerField(default=0)),
|
||||
("error_code", models.CharField(blank=True, max_length=80)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"run",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="steps",
|
||||
to="agents.agentrun",
|
||||
),
|
||||
),
|
||||
(
|
||||
"skill_version",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="run_steps",
|
||||
to="skills.skillversion",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AgentRunAttempt",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("attempt_type", models.CharField(default="initial", max_length=32)),
|
||||
("status", models.CharField(max_length=24)),
|
||||
("raw_output", models.TextField(blank=True)),
|
||||
("validation_error", models.TextField(blank=True)),
|
||||
("input_tokens", models.PositiveIntegerField(default=0)),
|
||||
("output_tokens", models.PositiveIntegerField(default=0)),
|
||||
("duration_ms", models.PositiveIntegerField(default=0)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"model_invocation",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="agent_attempts",
|
||||
to="modelhub.modelinvocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"step",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="attempts",
|
||||
to="agents.agentrunstep",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AgentVersion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("version", models.PositiveIntegerField()),
|
||||
("status", models.CharField(default="draft", max_length=24)),
|
||||
("system_prompt", models.TextField(blank=True)),
|
||||
("input_schema", models.JSONField(default=dict)),
|
||||
("example_input", models.JSONField(blank=True, default=dict)),
|
||||
("temperature_override", models.FloatField(blank=True, null=True)),
|
||||
("max_output_tokens_override", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("published_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_agent_versions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"definition",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="agents.agentdefinition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"model_deployment",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="agent_versions",
|
||||
to="modelhub.modeldeployment",
|
||||
),
|
||||
),
|
||||
(
|
||||
"published_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="published_agent_versions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-version"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AgentStepBinding",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("code", models.SlugField(max_length=80)),
|
||||
("order", models.PositiveIntegerField()),
|
||||
("input_mapping", models.JSONField(default=dict)),
|
||||
(
|
||||
"skill_version",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="agent_bindings",
|
||||
to="skills.skillversion",
|
||||
),
|
||||
),
|
||||
(
|
||||
"agent_version",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="steps",
|
||||
to="agents.agentversion",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["order"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="agentrun",
|
||||
name="agent_version",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="runs",
|
||||
to="agents.agentversion",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="agentdefinition",
|
||||
name="active_version",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="active_for_definitions",
|
||||
to="agents.agentversion",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="agentrunstep",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("run", "order"), name="unique_run_step_order"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="agentversion",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("definition", "version"), name="unique_agent_version"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="agentversion",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("status", "draft")),
|
||||
fields=("definition",),
|
||||
name="unique_agent_draft",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="agentstepbinding",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("agent_version", "code"), name="unique_agent_step_code"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="agentstepbinding",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("agent_version", "order"), name="unique_agent_step_order"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="agentrun",
|
||||
index=models.Index(
|
||||
fields=["status", "created_at"], name="agents_agen_status_27cafa_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="agentrun",
|
||||
index=models.Index(
|
||||
fields=["definition", "created_at"], name="agents_agen_definit_22ef17_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/agents/migrations/__init__.py
Normal file
1
backend/apps/agents/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
212
backend/apps/agents/models.py
Normal file
212
backend/apps/agents/models.py
Normal file
@@ -0,0 +1,212 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.modelhub.models import ModelDeployment, ModelInvocation
|
||||
from apps.skills.models import SkillVersion
|
||||
|
||||
|
||||
class AgentDefinition(models.Model):
|
||||
code = models.SlugField(max_length=80, unique=True)
|
||||
name = models.CharField(max_length=120)
|
||||
description = models.TextField(blank=True)
|
||||
active_version = models.ForeignKey(
|
||||
"AgentVersion",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="active_for_definitions",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_agents",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
class AgentVersion(models.Model):
|
||||
definition = models.ForeignKey(
|
||||
AgentDefinition,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
)
|
||||
version = models.PositiveIntegerField()
|
||||
status = models.CharField(max_length=24, default="draft")
|
||||
model_deployment = models.ForeignKey(
|
||||
ModelDeployment,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="agent_versions",
|
||||
)
|
||||
system_prompt = models.TextField(blank=True)
|
||||
input_schema = models.JSONField(default=dict)
|
||||
example_input = models.JSONField(default=dict, blank=True)
|
||||
temperature_override = models.FloatField(null=True, blank=True)
|
||||
max_output_tokens_override = models.PositiveIntegerField(null=True, blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_agent_versions",
|
||||
)
|
||||
published_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="published_agent_versions",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-version"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["definition", "version"],
|
||||
name="unique_agent_version",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["definition"],
|
||||
condition=Q(status="draft"),
|
||||
name="unique_agent_draft",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class AgentStepBinding(models.Model):
|
||||
agent_version = models.ForeignKey(
|
||||
AgentVersion,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="steps",
|
||||
)
|
||||
code = models.SlugField(max_length=80)
|
||||
order = models.PositiveIntegerField()
|
||||
skill_version = models.ForeignKey(
|
||||
SkillVersion,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="agent_bindings",
|
||||
)
|
||||
input_mapping = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["agent_version", "code"],
|
||||
name="unique_agent_step_code",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["agent_version", "order"],
|
||||
name="unique_agent_step_order",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class AgentRun(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
definition = models.ForeignKey(
|
||||
AgentDefinition,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="runs",
|
||||
)
|
||||
agent_version = models.ForeignKey(
|
||||
AgentVersion,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="runs",
|
||||
)
|
||||
sandbox = models.BooleanField(default=False)
|
||||
status = models.CharField(max_length=24, default="queued")
|
||||
input_payload = models.JSONField(default=dict)
|
||||
final_output = models.JSONField(null=True, blank=True)
|
||||
config_snapshot = models.JSONField(default=dict)
|
||||
current_step = models.CharField(max_length=80, blank=True)
|
||||
total_steps = models.PositiveIntegerField(default=0)
|
||||
input_tokens = models.PositiveIntegerField(default=0)
|
||||
output_tokens = models.PositiveIntegerField(default=0)
|
||||
duration_ms = models.PositiveIntegerField(default=0)
|
||||
task_id = models.CharField(max_length=120, blank=True)
|
||||
cancellation_requested_at = models.DateTimeField(null=True, blank=True)
|
||||
error_code = models.CharField(max_length=80, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="agent_runs",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["status", "created_at"]),
|
||||
models.Index(fields=["definition", "created_at"]),
|
||||
]
|
||||
|
||||
|
||||
class AgentRunStep(models.Model):
|
||||
run = models.ForeignKey(AgentRun, on_delete=models.CASCADE, related_name="steps")
|
||||
code = models.CharField(max_length=80)
|
||||
name = models.CharField(max_length=120)
|
||||
order = models.PositiveIntegerField()
|
||||
skill_version = models.ForeignKey(
|
||||
SkillVersion,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="run_steps",
|
||||
)
|
||||
status = models.CharField(max_length=24, default="queued")
|
||||
input_payload = models.JSONField(default=dict)
|
||||
output_payload = models.JSONField(null=True, blank=True)
|
||||
input_tokens = models.PositiveIntegerField(default=0)
|
||||
output_tokens = models.PositiveIntegerField(default=0)
|
||||
duration_ms = models.PositiveIntegerField(default=0)
|
||||
error_code = models.CharField(max_length=80, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["run", "order"], name="unique_run_step_order")
|
||||
]
|
||||
|
||||
|
||||
class AgentRunAttempt(models.Model):
|
||||
step = models.ForeignKey(
|
||||
AgentRunStep,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attempts",
|
||||
)
|
||||
model_invocation = models.ForeignKey(
|
||||
ModelInvocation,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="agent_attempts",
|
||||
)
|
||||
attempt_type = models.CharField(max_length=32, default="initial")
|
||||
status = models.CharField(max_length=24)
|
||||
raw_output = models.TextField(blank=True)
|
||||
validation_error = models.TextField(blank=True)
|
||||
input_tokens = models.PositiveIntegerField(default=0)
|
||||
output_tokens = models.PositiveIntegerField(default=0)
|
||||
duration_ms = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
141
backend/apps/agents/schemas.py
Normal file
141
backend/apps/agents/schemas.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class AgentStepIn(Schema):
|
||||
code: str
|
||||
skill_version_id: int
|
||||
input_mapping: dict[str, str]
|
||||
|
||||
|
||||
class AgentCreateIn(Schema):
|
||||
code: str
|
||||
name: str
|
||||
description: str = ""
|
||||
model_deployment_id: int
|
||||
system_prompt: str = ""
|
||||
input_schema: dict[str, Any] = {"type": "object"}
|
||||
example_input: dict[str, Any] = {}
|
||||
temperature_override: float | None = None
|
||||
max_output_tokens_override: int | None = None
|
||||
steps: list[AgentStepIn]
|
||||
|
||||
|
||||
class AgentDefinitionUpdateIn(Schema):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentDraftUpdateIn(Schema):
|
||||
model_deployment_id: int | None = None
|
||||
system_prompt: str | None = None
|
||||
input_schema: dict[str, Any] | None = None
|
||||
example_input: dict[str, Any] | None = None
|
||||
temperature_override: float | None = None
|
||||
max_output_tokens_override: int | None = None
|
||||
steps: list[AgentStepIn] | None = None
|
||||
|
||||
|
||||
class AgentStepOut(Schema):
|
||||
id: int
|
||||
code: str
|
||||
order: int
|
||||
skill_version_id: int
|
||||
skill_name: str
|
||||
skill_version: int
|
||||
skill_status: str
|
||||
input_mapping: dict[str, str]
|
||||
|
||||
|
||||
class AgentVersionOut(Schema):
|
||||
id: int
|
||||
version: int
|
||||
status: str
|
||||
model_deployment_id: int
|
||||
model_display_name: str
|
||||
system_prompt: str
|
||||
input_schema: dict[str, Any]
|
||||
example_input: dict[str, Any]
|
||||
temperature_override: float | None
|
||||
max_output_tokens_override: int | None
|
||||
steps: list[AgentStepOut]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class AgentDefinitionOut(Schema):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
active_version: int | None
|
||||
draft_version: int | None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AgentDetailOut(AgentDefinitionOut):
|
||||
versions: list[AgentVersionOut]
|
||||
|
||||
|
||||
class AgentRunIn(Schema):
|
||||
version: str | int = "draft"
|
||||
input: dict[str, Any]
|
||||
|
||||
|
||||
class AgentRunAttemptOut(Schema):
|
||||
id: int
|
||||
attempt_type: str
|
||||
status: str
|
||||
raw_output: str
|
||||
validation_error: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
duration_ms: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentRunStepOut(Schema):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
order: int
|
||||
status: str
|
||||
input_payload: dict[str, Any]
|
||||
output_payload: Any | None
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
duration_ms: int
|
||||
error_code: str
|
||||
error_message: str
|
||||
attempts: list[AgentRunAttemptOut]
|
||||
|
||||
|
||||
class AgentRunSummaryOut(Schema):
|
||||
public_id: UUID
|
||||
agent_id: int
|
||||
agent_name: str
|
||||
agent_version: int
|
||||
status: str
|
||||
sandbox: bool
|
||||
current_step: str
|
||||
total_steps: int
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
duration_ms: int
|
||||
error_code: str
|
||||
error_message: str
|
||||
created_at: datetime
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class AgentRunDetailOut(AgentRunSummaryOut):
|
||||
input_payload: dict[str, Any]
|
||||
final_output: Any | None
|
||||
config_snapshot: dict[str, Any]
|
||||
cancellation_requested_at: datetime | None
|
||||
steps: list[AgentRunStepOut]
|
||||
390
backend/apps/agents/services.py
Normal file
390
backend/apps/agents/services.py
Normal file
@@ -0,0 +1,390 @@
|
||||
import json
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import (
|
||||
AgentDefinition,
|
||||
AgentRun,
|
||||
AgentRunAttempt,
|
||||
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
|
||||
|
||||
|
||||
def validate_agent_version(version: AgentVersion, *, publishing: bool = False) -> None:
|
||||
validate_json_schema(version.input_schema, "Agent 输入 Schema")
|
||||
if version.example_input:
|
||||
validate_payload(version.input_schema, version.example_input, "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"))
|
||||
if not steps:
|
||||
raise AgentValidationError("Agent 至少需要一个 Skill 步骤")
|
||||
if len(steps) > 10:
|
||||
raise AgentValidationError("Agent 最多允许 10 个步骤")
|
||||
known_steps: set[str] = set()
|
||||
for step in steps:
|
||||
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),
|
||||
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,
|
||||
input_mapping=deepcopy(step.input_mapping),
|
||||
)
|
||||
for step in source.steps.all()
|
||||
]
|
||||
)
|
||||
return draft
|
||||
|
||||
|
||||
def build_config_snapshot(version: AgentVersion) -> dict:
|
||||
deployment = version.model_deployment
|
||||
return {
|
||||
"agent": {
|
||||
"id": version.definition_id,
|
||||
"code": version.definition.code,
|
||||
"version": version.version,
|
||||
"status": version.status,
|
||||
},
|
||||
"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,
|
||||
}
|
||||
for step in version.steps.select_related("skill_version__definition")
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
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 _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,
|
||||
) -> ModelInvocation:
|
||||
version = run.agent_version
|
||||
messages = []
|
||||
if version.system_prompt:
|
||||
messages.append({"role": "system", "content": version.system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
invocation = ModelInvocation.objects.create(
|
||||
deployment=version.model_deployment,
|
||||
invocation_type="agent_step",
|
||||
input_snapshot={"messages": messages, "run_id": str(run.public_id), "step": step.code},
|
||||
created_by=run.created_by,
|
||||
)
|
||||
execute_invocation(
|
||||
invocation,
|
||||
messages,
|
||||
temperature=version.temperature_override,
|
||||
max_output_tokens=version.max_output_tokens_override,
|
||||
)
|
||||
_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, "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) -> 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.save(update_fields=["status", "started_at"])
|
||||
step_outputs: dict[str, dict] = {}
|
||||
try:
|
||||
bindings = list(
|
||||
run.agent_version.steps.select_related(
|
||||
"skill_version__definition",
|
||||
)
|
||||
)
|
||||
for binding in bindings:
|
||||
run.refresh_from_db(fields=["cancellation_requested_at"])
|
||||
if run.cancellation_requested_at:
|
||||
run.status = "cancelled"
|
||||
break
|
||||
run.current_step = binding.code
|
||||
run.save(update_fields=["current_step"])
|
||||
step = AgentRunStep.objects.create(
|
||||
run=run,
|
||||
code=binding.code,
|
||||
name=binding.skill_version.definition.name,
|
||||
order=binding.order,
|
||||
skill_version=binding.skill_version,
|
||||
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")
|
||||
if invocation.status != "succeeded":
|
||||
raise AgentValidationError(invocation.error_message or "模型调用失败")
|
||||
try:
|
||||
output = _parse_json_output(invocation.output_text)
|
||||
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)
|
||||
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}"
|
||||
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"
|
||||
break
|
||||
if run.status == "running":
|
||||
run.status = "succeeded"
|
||||
run.final_output = step.output_payload if bindings else None
|
||||
except Exception as error:
|
||||
run.status = "failed"
|
||||
run.error_code = "runtime_error"
|
||||
run.error_message = "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()
|
||||
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"],
|
||||
input_mapping=item.get("input_mapping", {}),
|
||||
)
|
||||
for index, item in enumerate(steps)
|
||||
]
|
||||
)
|
||||
validate_agent_version(version)
|
||||
55
backend/apps/agents/tasks.py
Normal file
55
backend/apps/agents/tasks.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.agents.services import execute_agent_run as execute_agent_run_service
|
||||
from apps.audit.models import OperationAuditLog
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="apps.agents.tasks.execute_agent_run",
|
||||
soft_time_limit=600,
|
||||
time_limit=630,
|
||||
)
|
||||
def execute_agent_run(self, run_id: int) -> None:
|
||||
try:
|
||||
execute_agent_run_service(run_id)
|
||||
except SoftTimeLimitExceeded:
|
||||
run = AgentRun.objects.get(id=run_id)
|
||||
run.status = "timed_out"
|
||||
run.error_code = "run_timeout"
|
||||
run.error_message = "Agent 运行超过 10 分钟限制"
|
||||
run.finished_at = timezone.now()
|
||||
run.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"error_code",
|
||||
"error_message",
|
||||
"finished_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="apps.agents.tasks.cleanup_expired_agent_runs")
|
||||
def cleanup_expired_agent_runs() -> int:
|
||||
cutoff = timezone.now() - timedelta(days=settings.AI_RUN_RETENTION_DAYS)
|
||||
queryset = AgentRun.objects.filter(created_at__lt=cutoff)
|
||||
count = queryset.count()
|
||||
if count:
|
||||
OperationAuditLog.objects.create(
|
||||
module="agents",
|
||||
action="cleanup_runs",
|
||||
target_type="agent_run",
|
||||
request_snapshot={
|
||||
"deleted_count": count,
|
||||
"retention_days": settings.AI_RUN_RETENTION_DAYS,
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
queryset.delete()
|
||||
return count
|
||||
1
backend/apps/agents/tests/__init__.py
Normal file
1
backend/apps/agents/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
91
backend/apps/agents/tests/helpers.py
Normal file
91
backend/apps/agents/tests/helpers.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.modelhub.models import ModelDeployment, ModelProvider
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
|
||||
|
||||
def create_deployment():
|
||||
provider = ModelProvider.objects.create(
|
||||
name="模型渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
)
|
||||
return ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="支持模型",
|
||||
model_name="support-model",
|
||||
)
|
||||
|
||||
|
||||
def create_skill(code: str, output_schema: dict, input_schema: dict | None = None):
|
||||
definition = SkillDefinition.objects.create(code=code, name=code)
|
||||
version = SkillVersion.objects.create(
|
||||
definition=definition,
|
||||
version=1,
|
||||
status="published",
|
||||
prompt_template="处理:{{ input.value }}",
|
||||
input_schema=input_schema
|
||||
or {
|
||||
"type": "object",
|
||||
"properties": {"value": {}},
|
||||
"required": ["value"],
|
||||
},
|
||||
output_schema=output_schema,
|
||||
)
|
||||
definition.active_version = version
|
||||
definition.save(update_fields=["active_version"])
|
||||
return version
|
||||
|
||||
|
||||
def create_agent(deployment=None, *, published=False, two_steps=True):
|
||||
deployment = deployment or create_deployment()
|
||||
first_skill = create_skill(
|
||||
"first-skill",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"summary": {"type": "string"}},
|
||||
"required": ["summary"],
|
||||
},
|
||||
)
|
||||
second_skill = None
|
||||
if two_steps:
|
||||
second_skill = create_skill(
|
||||
"second-skill",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"reply": {"type": "string"}},
|
||||
"required": ["reply"],
|
||||
},
|
||||
)
|
||||
definition = AgentDefinition.objects.create(code="support-agent", name="支持 Agent")
|
||||
version = AgentVersion.objects.create(
|
||||
definition=definition,
|
||||
version=1,
|
||||
status="published" if published else "draft",
|
||||
model_deployment=deployment,
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"problem": {"type": "string"}},
|
||||
"required": ["problem"],
|
||||
},
|
||||
example_input={"problem": "无法登录"},
|
||||
)
|
||||
AgentStepBinding.objects.create(
|
||||
agent_version=version,
|
||||
code="structure",
|
||||
order=1,
|
||||
skill_version=first_skill,
|
||||
input_mapping={"value": "$input.problem"},
|
||||
)
|
||||
if second_skill:
|
||||
AgentStepBinding.objects.create(
|
||||
agent_version=version,
|
||||
code="reply",
|
||||
order=2,
|
||||
skill_version=second_skill,
|
||||
input_mapping={"value": "$steps.structure.output.summary"},
|
||||
)
|
||||
if published:
|
||||
definition.active_version = version
|
||||
definition.save(update_fields=["active_version"])
|
||||
return definition, version
|
||||
76
backend/apps/agents/tests/test_agents_api.py
Normal file
76
backend/apps/agents/tests/test_agents_api.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.modelhub.services import CompletionResult
|
||||
|
||||
from .helpers import create_agent, create_deployment
|
||||
|
||||
|
||||
def login_admin(client):
|
||||
call_command("bootstrap_governance")
|
||||
user = get_user_model().objects.get(username="admin")
|
||||
client.force_login(user)
|
||||
return user
|
||||
|
||||
|
||||
def test_admin_publishes_and_runs_agent(client, db, monkeypatch):
|
||||
login_admin(client)
|
||||
definition, version = create_agent()
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: CompletionResult('{"summary":"ok","reply":"ok"}', 3, 2),
|
||||
)
|
||||
|
||||
published = client.post(f"/api/agents/{definition.id}/publish")
|
||||
assert published.status_code == 200
|
||||
assert published.json()["status"] == "published"
|
||||
|
||||
run = client.post(
|
||||
f"/api/agents/{definition.id}/runs",
|
||||
data={"version": 1, "input": {"problem": "无法登录"}},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert run.status_code == 200
|
||||
assert run.json()["status"] == "succeeded"
|
||||
public_id = run.json()["public_id"]
|
||||
detail = client.get(f"/api/agent-runs/{public_id}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["config_snapshot"]["agent"]["version"] == 1
|
||||
|
||||
|
||||
def test_publishing_rejects_draft_skill(client, db):
|
||||
login_admin(client)
|
||||
definition, version = create_agent()
|
||||
binding = version.steps.first()
|
||||
binding.skill_version.status = "draft"
|
||||
binding.skill_version.save(update_fields=["status"])
|
||||
|
||||
response = client.post(f"/api/agents/{definition.id}/publish")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "已发布 Skill" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_agent_run_permission_is_required(client, db):
|
||||
definition, _ = create_agent(published=True)
|
||||
user = get_user_model().objects.create_user(username="requester", password="password")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/agents/{definition.id}/runs",
|
||||
data={"version": 1, "input": {"problem": "无法登录"}},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_seed_command_creates_support_agent(client, db):
|
||||
deployment = create_deployment()
|
||||
|
||||
call_command("seed_ai_demo", deployment_id=deployment.id)
|
||||
|
||||
from apps.agents.models import AgentDefinition
|
||||
|
||||
agent = AgentDefinition.objects.get(code="support-analysis")
|
||||
assert agent.versions.get(status="draft").steps.count() == 3
|
||||
114
backend/apps/agents/tests/test_runtime.py
Normal file
114
backend/apps/agents/tests/test_runtime.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.agents.services import build_config_snapshot, execute_agent_run
|
||||
from apps.agents.tasks import cleanup_expired_agent_runs
|
||||
from apps.modelhub.services import CompletionResult
|
||||
|
||||
from .helpers import create_agent
|
||||
|
||||
|
||||
def create_run(version):
|
||||
return AgentRun.objects.create(
|
||||
definition=version.definition,
|
||||
agent_version=version,
|
||||
sandbox=version.status == "draft",
|
||||
input_payload={"problem": "无法登录"},
|
||||
config_snapshot=build_config_snapshot(version),
|
||||
total_steps=version.steps.count(),
|
||||
)
|
||||
|
||||
|
||||
def test_ordered_runtime_records_steps_usage_and_final_output(db, monkeypatch):
|
||||
_, version = create_agent()
|
||||
outputs = iter(
|
||||
[
|
||||
CompletionResult('{"summary":"账号异常"}', 10, 5),
|
||||
CompletionResult('{"reply":"请重置密码"}', 12, 6),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: next(outputs),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "succeeded"
|
||||
assert run.final_output == {"reply": "请重置密码"}
|
||||
assert run.input_tokens == 22
|
||||
assert run.output_tokens == 11
|
||||
assert list(run.steps.values_list("code", flat=True)) == ["structure", "reply"]
|
||||
|
||||
|
||||
def test_invalid_structure_is_repaired_once(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
outputs = iter(
|
||||
[
|
||||
CompletionResult("not-json", 4, 2),
|
||||
CompletionResult('{"summary":"已修复"}', 5, 3),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: next(outputs),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
step = run.steps.get()
|
||||
assert run.status == "succeeded"
|
||||
assert step.attempts.count() == 2
|
||||
assert list(step.attempts.values_list("attempt_type", flat=True)) == ["initial", "repair"]
|
||||
|
||||
|
||||
def test_second_invalid_structure_fails_and_stops(db, monkeypatch):
|
||||
_, version = create_agent()
|
||||
outputs = iter(
|
||||
[
|
||||
CompletionResult("not-json", 4, 2),
|
||||
CompletionResult("still-not-json", 5, 3),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: next(outputs),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "failed"
|
||||
assert run.steps.count() == 1
|
||||
assert run.steps.get().status == "failed"
|
||||
|
||||
|
||||
def test_cancellation_before_first_step_stops_run(db):
|
||||
_, version = create_agent()
|
||||
run = create_run(version)
|
||||
run.cancellation_requested_at = timezone.now()
|
||||
run.save(update_fields=["cancellation_requested_at"])
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "cancelled"
|
||||
assert run.steps.count() == 0
|
||||
|
||||
|
||||
def test_cleanup_removes_expired_runs_but_keeps_audit(db, settings):
|
||||
_, version = create_agent()
|
||||
run = create_run(version)
|
||||
AgentRun.objects.filter(id=run.id).update(created_at=timezone.now() - timedelta(days=91))
|
||||
|
||||
deleted = cleanup_expired_agent_runs()
|
||||
|
||||
assert deleted == 1
|
||||
assert not AgentRun.objects.filter(id=run.id).exists()
|
||||
@@ -23,6 +23,7 @@ INSTALLED_APPS = [
|
||||
"apps.knowledge",
|
||||
"apps.modelhub",
|
||||
"apps.skills",
|
||||
"apps.agents",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -3,6 +3,8 @@ from django.urls import path
|
||||
from ninja import NinjaAPI
|
||||
|
||||
from apps.accounts.api import router as accounts_router
|
||||
from apps.agents.api import router as agents_router
|
||||
from apps.agents.api import runs_router as agent_runs_router
|
||||
from apps.audit.api import router as audit_router
|
||||
from apps.knowledge.api import router as knowledge_router
|
||||
from apps.modelhub.api import router as modelhub_router
|
||||
@@ -20,6 +22,8 @@ api.add_router("/solutions", solutions_router)
|
||||
api.add_router("/knowledge", knowledge_router)
|
||||
api.add_router("/modelhub", modelhub_router)
|
||||
api.add_router("/skills", skills_router)
|
||||
api.add_router("/agents", agents_router)
|
||||
api.add_router("/agent-runs", agent_runs_router)
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
@@ -62,6 +62,22 @@ services:
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/backend.Dockerfile
|
||||
command: celery -A config.celery beat -l info
|
||||
env_file:
|
||||
- path: ../.env.example
|
||||
required: true
|
||||
- path: ../.env
|
||||
required: false
|
||||
depends_on:
|
||||
- backend
|
||||
- redis
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
|
||||
Reference in New Issue
Block a user