feat: add ordered agent runtime

This commit is contained in:
hailin
2026-07-14 13:55:18 +08:00
parent ed3f6e8516
commit 9d4fae58bd
19 changed files with 2024 additions and 0 deletions

View File

@@ -0,0 +1 @@

380
backend/apps/agents/api.py Normal file
View 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)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AgentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.agents"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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}."
)
)

View 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"
),
),
]

View File

@@ -0,0 +1 @@

View 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"]

View 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]

View 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)

View 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

View File

@@ -0,0 +1 @@

View 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

View 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

View 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()

View File

@@ -23,6 +23,7 @@ INSTALLED_APPS = [
"apps.knowledge",
"apps.modelhub",
"apps.skills",
"apps.agents",
]
MIDDLEWARE = [

View File

@@ -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),

View File

@@ -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: ..