feat(agent-runtime): 实现运行审计与状态服务
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Agent 运行记录与生命周期应用。"""
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import AgentRun, AgentRunEvent, HumanApproval, ToolCall
|
||||||
|
from .services import resolve_approval
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlyAuditAdmin(admin.ModelAdmin):
|
||||||
|
"""审计记录允许查看但禁止通过 Admin 改写或删除。"""
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return request.user.is_staff if obj is None else False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AgentRun)
|
||||||
|
class AgentRunAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "owner", "status", "created_at", "finished_at")
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("title", "owner__username", "trace_id")
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(AgentRunEvent, ReadOnlyAuditAdmin)
|
||||||
|
admin.site.register(ToolCall, ReadOnlyAuditAdmin)
|
||||||
|
|
||||||
|
@admin.register(HumanApproval)
|
||||||
|
class HumanApprovalAdmin(admin.ModelAdmin):
|
||||||
|
"""人工确认正文只读,通过受控动作执行领域状态转换。"""
|
||||||
|
|
||||||
|
list_display = ("approval_type", "run", "status", "requested_at", "resolved_by")
|
||||||
|
list_filter = ("status", "approval_type")
|
||||||
|
readonly_fields = tuple(field.name for field in HumanApproval._meta.fields)
|
||||||
|
actions = ("approve_selected", "reject_selected")
|
||||||
|
|
||||||
|
@admin.action(description="通过选中的待确认请求")
|
||||||
|
def approve_selected(self, request, queryset):
|
||||||
|
for approval in queryset:
|
||||||
|
resolve_approval(request.user, approval.id, True, {"source": "admin"})
|
||||||
|
|
||||||
|
@admin.action(description="拒绝选中的待确认请求")
|
||||||
|
def reject_selected(self, request, queryset):
|
||||||
|
for approval in queryset:
|
||||||
|
resolve_approval(request.user, approval.id, False, {"source": "admin"})
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRuntimeConfig(AppConfig):
|
||||||
|
"""Agent 运行持久化应用配置。"""
|
||||||
|
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "agent_runtime"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""OpenAI Agents SDK 与本地运行模型之间的稳定适配边界。"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentExecutionRequest:
|
||||||
|
"""不包含 API Key、Cookie 等秘密的运行请求。"""
|
||||||
|
|
||||||
|
agent_run_id: str
|
||||||
|
user_id: int
|
||||||
|
input_summary: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentExecutionResult:
|
||||||
|
"""Gateway 返回的脱敏运行结果。"""
|
||||||
|
|
||||||
|
succeeded: bool
|
||||||
|
output_summary: dict = field(default_factory=dict)
|
||||||
|
error_code: str = ""
|
||||||
|
error_summary: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunnerGateway(Protocol):
|
||||||
|
"""第二阶段真实 SDK Gateway 必须实现的最小协议。"""
|
||||||
|
|
||||||
|
def run(self, request: AgentExecutionRequest) -> AgentExecutionResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class StubAgentRunnerGateway:
|
||||||
|
"""测试使用的确定性替身,不发起任何外部或付费调用。"""
|
||||||
|
|
||||||
|
def __init__(self, result: AgentExecutionResult):
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
def run(self, request: AgentExecutionRequest) -> AgentExecutionResult:
|
||||||
|
return self.result
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Generated by Django 6.0.8 on 2026-09-09 02:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AgentRun',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('title', models.CharField(max_length=160, verbose_name='标题')),
|
||||||
|
('status', models.CharField(choices=[('pending', '等待'), ('running', '运行中'), ('waiting_approval', '等待确认'), ('succeeded', '成功'), ('failed', '失败'), ('cancelled', '已取消')], default='pending', max_length=32, verbose_name='状态')),
|
||||||
|
('lock_version', models.PositiveBigIntegerField(default=0, verbose_name='锁版本')),
|
||||||
|
('input_summary', models.JSONField(default=dict, verbose_name='输入摘要')),
|
||||||
|
('output_summary', models.JSONField(blank=True, null=True, verbose_name='输出摘要')),
|
||||||
|
('agent_name', models.CharField(default='job_research', max_length=100, verbose_name='Agent 名称')),
|
||||||
|
('agent_version', models.CharField(blank=True, max_length=40, verbose_name='Agent 版本')),
|
||||||
|
('instruction_version', models.CharField(blank=True, max_length=40, verbose_name='指令版本')),
|
||||||
|
('toolset_version', models.CharField(blank=True, max_length=40, verbose_name='工具集版本')),
|
||||||
|
('model_name', models.CharField(blank=True, max_length=100, verbose_name='模型名称')),
|
||||||
|
('trace_id', models.CharField(blank=True, max_length=128, verbose_name='追踪标识')),
|
||||||
|
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
|
||||||
|
('duration_ms', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='耗时毫秒')),
|
||||||
|
('input_tokens', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='输入 Token')),
|
||||||
|
('output_tokens', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='输出 Token')),
|
||||||
|
('estimated_cost', models.DecimalField(blank=True, decimal_places=6, max_digits=12, null=True, verbose_name='估算费用')),
|
||||||
|
('error_code', models.CharField(blank=True, max_length=80, verbose_name='错误码')),
|
||||||
|
('error_summary', models.TextField(blank=True, verbose_name='错误摘要')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(app_label)s_%(class)s_items', to=settings.AUTH_USER_MODEL, verbose_name='所属用户')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AgentRunEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('sequence', models.PositiveBigIntegerField(verbose_name='事件序号')),
|
||||||
|
('event_type', models.CharField(max_length=64, verbose_name='事件类型')),
|
||||||
|
('summary', models.CharField(max_length=240, verbose_name='摘要')),
|
||||||
|
('payload_summary', models.JSONField(default=dict, verbose_name='数据摘要')),
|
||||||
|
('occurred_at', models.DateTimeField(verbose_name='发生时间')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='记录时间')),
|
||||||
|
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='agent_runtime.agentrun')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('sequence',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HumanApproval',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('request_key', models.CharField(max_length=128, verbose_name='请求键')),
|
||||||
|
('approval_type', models.CharField(max_length=64, verbose_name='确认类型')),
|
||||||
|
('status', models.CharField(choices=[('pending', '待处理'), ('approved', '已通过'), ('rejected', '已拒绝'), ('expired', '已过期'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
|
||||||
|
('request_summary', models.JSONField(default=dict, verbose_name='请求摘要')),
|
||||||
|
('decision_summary', models.JSONField(blank=True, null=True, verbose_name='处理摘要')),
|
||||||
|
('requested_at', models.DateTimeField(verbose_name='请求时间')),
|
||||||
|
('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
|
||||||
|
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='resolved_agent_approvals', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approvals', to='agent_runtime.agentrun')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ToolCall',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('call_id', models.CharField(max_length=128, verbose_name='调用标识')),
|
||||||
|
('tool_name', models.CharField(max_length=100, verbose_name='工具名称')),
|
||||||
|
('tool_version', models.CharField(blank=True, max_length=40, verbose_name='工具版本')),
|
||||||
|
('idempotency_key', models.CharField(blank=True, max_length=128, verbose_name='幂等键')),
|
||||||
|
('status', models.CharField(choices=[('started', '已开始'), ('succeeded', '成功'), ('failed', '失败')], default='started', max_length=20, verbose_name='状态')),
|
||||||
|
('arguments_summary', models.JSONField(default=dict, verbose_name='参数摘要')),
|
||||||
|
('result_summary', models.JSONField(blank=True, null=True, verbose_name='结果摘要')),
|
||||||
|
('error_code', models.CharField(blank=True, max_length=80, verbose_name='错误码')),
|
||||||
|
('error_summary', models.TextField(blank=True, verbose_name='错误摘要')),
|
||||||
|
('started_at', models.DateTimeField(verbose_name='开始时间')),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
|
||||||
|
('duration_ms', models.PositiveBigIntegerField(blank=True, null=True, verbose_name='耗时毫秒')),
|
||||||
|
('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tool_calls', to='agent_runtime.agentrun')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='agentrun',
|
||||||
|
index=models.Index(fields=['owner', 'created_at'], name='run_owner_created_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='agentrun',
|
||||||
|
constraint=models.CheckConstraint(condition=models.Q(('lock_version__gte', 0)), name='run_lock_nonnegative'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='agentrunevent',
|
||||||
|
constraint=models.UniqueConstraint(fields=('run', 'sequence'), name='event_run_sequence_uniq'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='humanapproval',
|
||||||
|
index=models.Index(fields=['status', 'requested_at'], name='approval_status_time_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='humanapproval',
|
||||||
|
constraint=models.UniqueConstraint(fields=('run', 'request_key'), name='approval_run_key_uniq'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='toolcall',
|
||||||
|
index=models.Index(fields=['run', 'started_at'], name='tool_run_started_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='toolcall',
|
||||||
|
constraint=models.UniqueConstraint(fields=('run', 'call_id'), name='tool_run_call_uniq'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='toolcall',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key', ''), _negated=True), fields=('run', 'idempotency_key'), name='tool_run_idempotency_uniq'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Agent 运行、事件、工具调用和人工确认模型。"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from common.models import TimeStampedModel, UserOwnedModel
|
||||||
|
|
||||||
|
|
||||||
|
class RunStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "等待"
|
||||||
|
RUNNING = "running", "运行中"
|
||||||
|
WAITING_APPROVAL = "waiting_approval", "等待确认"
|
||||||
|
SUCCEEDED = "succeeded", "成功"
|
||||||
|
FAILED = "failed", "失败"
|
||||||
|
CANCELLED = "cancelled", "已取消"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRun(UserOwnedModel):
|
||||||
|
"""一次可审计的 Agent 运行;摘要字段不得保存敏感原文。"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
title = models.CharField("标题", max_length=160)
|
||||||
|
status = models.CharField("状态", max_length=32, choices=RunStatus, default=RunStatus.PENDING)
|
||||||
|
lock_version = models.PositiveBigIntegerField("锁版本", default=0)
|
||||||
|
input_summary = models.JSONField("输入摘要", default=dict)
|
||||||
|
output_summary = models.JSONField("输出摘要", null=True, blank=True)
|
||||||
|
agent_name = models.CharField("Agent 名称", max_length=100, default="job_research")
|
||||||
|
agent_version = models.CharField("Agent 版本", max_length=40, blank=True)
|
||||||
|
instruction_version = models.CharField("指令版本", max_length=40, blank=True)
|
||||||
|
toolset_version = models.CharField("工具集版本", max_length=40, blank=True)
|
||||||
|
model_name = models.CharField("模型名称", max_length=100, blank=True)
|
||||||
|
trace_id = models.CharField("追踪标识", max_length=128, blank=True)
|
||||||
|
started_at = models.DateTimeField("开始时间", null=True, blank=True)
|
||||||
|
finished_at = models.DateTimeField("结束时间", null=True, blank=True)
|
||||||
|
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
|
||||||
|
input_tokens = models.PositiveBigIntegerField("输入 Token", null=True, blank=True)
|
||||||
|
output_tokens = models.PositiveBigIntegerField("输出 Token", null=True, blank=True)
|
||||||
|
estimated_cost = models.DecimalField("估算费用", max_digits=12, decimal_places=6, null=True, blank=True)
|
||||||
|
error_code = models.CharField("错误码", max_length=80, blank=True)
|
||||||
|
error_summary = models.TextField("错误摘要", blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = [models.Index(fields=("owner", "created_at"), name="run_owner_created_idx")]
|
||||||
|
constraints = [models.CheckConstraint(condition=Q(lock_version__gte=0), name="run_lock_nonnegative")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunEvent(models.Model):
|
||||||
|
"""按序追加的不可变运行事件。"""
|
||||||
|
|
||||||
|
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="events")
|
||||||
|
sequence = models.PositiveBigIntegerField("事件序号")
|
||||||
|
event_type = models.CharField("事件类型", max_length=64)
|
||||||
|
summary = models.CharField("摘要", max_length=240)
|
||||||
|
payload_summary = models.JSONField("数据摘要", default=dict)
|
||||||
|
occurred_at = models.DateTimeField("发生时间")
|
||||||
|
created_at = models.DateTimeField("记录时间", auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("sequence",)
|
||||||
|
constraints = [models.UniqueConstraint(fields=("run", "sequence"), name="event_run_sequence_uniq")]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallStatus(models.TextChoices):
|
||||||
|
STARTED = "started", "已开始"
|
||||||
|
SUCCEEDED = "succeeded", "成功"
|
||||||
|
FAILED = "failed", "失败"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCall(TimeStampedModel):
|
||||||
|
"""记录一次工具调用的脱敏输入、结果和失败信息。"""
|
||||||
|
|
||||||
|
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="tool_calls")
|
||||||
|
call_id = models.CharField("调用标识", max_length=128)
|
||||||
|
tool_name = models.CharField("工具名称", max_length=100)
|
||||||
|
tool_version = models.CharField("工具版本", max_length=40, blank=True)
|
||||||
|
idempotency_key = models.CharField("幂等键", max_length=128, blank=True)
|
||||||
|
status = models.CharField("状态", max_length=20, choices=ToolCallStatus, default=ToolCallStatus.STARTED)
|
||||||
|
arguments_summary = models.JSONField("参数摘要", default=dict)
|
||||||
|
result_summary = models.JSONField("结果摘要", null=True, blank=True)
|
||||||
|
error_code = models.CharField("错误码", max_length=80, blank=True)
|
||||||
|
error_summary = models.TextField("错误摘要", blank=True)
|
||||||
|
started_at = models.DateTimeField("开始时间")
|
||||||
|
finished_at = models.DateTimeField("结束时间", null=True, blank=True)
|
||||||
|
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [models.Index(fields=("run", "started_at"), name="tool_run_started_idx")]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=("run", "call_id"), name="tool_run_call_uniq"),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("run", "idempotency_key"),
|
||||||
|
condition=~Q(idempotency_key=""),
|
||||||
|
name="tool_run_idempotency_uniq",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "待处理"
|
||||||
|
APPROVED = "approved", "已通过"
|
||||||
|
REJECTED = "rejected", "已拒绝"
|
||||||
|
EXPIRED = "expired", "已过期"
|
||||||
|
CANCELLED = "cancelled", "已取消"
|
||||||
|
|
||||||
|
|
||||||
|
class HumanApproval(TimeStampedModel):
|
||||||
|
"""保存高风险动作的人工确认请求与处理证据。"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="approvals")
|
||||||
|
request_key = models.CharField("请求键", max_length=128)
|
||||||
|
approval_type = models.CharField("确认类型", max_length=64)
|
||||||
|
status = models.CharField("状态", max_length=20, choices=ApprovalStatus, default=ApprovalStatus.PENDING)
|
||||||
|
request_summary = models.JSONField("请求摘要", default=dict)
|
||||||
|
decision_summary = models.JSONField("处理摘要", null=True, blank=True)
|
||||||
|
requested_at = models.DateTimeField("请求时间")
|
||||||
|
resolved_at = models.DateTimeField("处理时间", null=True, blank=True)
|
||||||
|
resolved_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="resolved_agent_approvals",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [models.Index(fields=("status", "requested_at"), name="approval_status_time_idx")]
|
||||||
|
constraints = [models.UniqueConstraint(fields=("run", "request_key"), name="approval_run_key_uniq")]
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Agent Run 的事务、权限和状态转换服务。"""
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from common.exceptions import InvalidStateTransition, PermissionDenied
|
||||||
|
from common.logging import sanitize_summary
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AgentRun,
|
||||||
|
AgentRunEvent,
|
||||||
|
ApprovalStatus,
|
||||||
|
HumanApproval,
|
||||||
|
RunStatus,
|
||||||
|
ToolCall,
|
||||||
|
ToolCallStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRANSITIONS = {
|
||||||
|
RunStatus.PENDING: {RunStatus.RUNNING, RunStatus.CANCELLED},
|
||||||
|
RunStatus.RUNNING: {RunStatus.WAITING_APPROVAL, RunStatus.SUCCEEDED, RunStatus.FAILED, RunStatus.CANCELLED},
|
||||||
|
RunStatus.WAITING_APPROVAL: {RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runs_for_user(user):
|
||||||
|
"""普通站点始终限制为当前用户数据,管理员跨用户查询使用 Admin。"""
|
||||||
|
|
||||||
|
return AgentRun.objects.filter(owner=user)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_event(run, event_type: str, summary: str, payload=None):
|
||||||
|
"""在持有 Run 写锁的事务中分配下一个事件序号。"""
|
||||||
|
|
||||||
|
last = run.events.aggregate(value=Max("sequence"))["value"] or 0
|
||||||
|
return AgentRunEvent.objects.create(
|
||||||
|
run=run,
|
||||||
|
sequence=last + 1,
|
||||||
|
event_type=event_type,
|
||||||
|
summary=summary,
|
||||||
|
payload_summary=sanitize_summary(payload or {}),
|
||||||
|
occurred_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_run(owner, title: str, input_summary=None) -> AgentRun:
|
||||||
|
"""创建等待运行的记录,并原子追加创建事件。"""
|
||||||
|
|
||||||
|
run = AgentRun.objects.create(
|
||||||
|
owner=owner, title=title.strip(), input_summary=sanitize_summary(input_summary or {})
|
||||||
|
)
|
||||||
|
_append_event(run, "run_created", "运行记录已创建")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def transition_run(run_id, target_status: str, *, error_code="", error_summary="", output=None):
|
||||||
|
"""校验并执行一次状态转换,主记录与审计事件同时提交。"""
|
||||||
|
|
||||||
|
run = AgentRun.objects.select_for_update().get(pk=run_id)
|
||||||
|
if target_status not in TRANSITIONS.get(run.status, set()):
|
||||||
|
raise InvalidStateTransition(f"不允许从 {run.status} 转换到 {target_status}。")
|
||||||
|
now = timezone.now()
|
||||||
|
run.status = target_status
|
||||||
|
run.lock_version += 1
|
||||||
|
if target_status == RunStatus.RUNNING and run.started_at is None:
|
||||||
|
run.started_at = now
|
||||||
|
if target_status in {RunStatus.SUCCEEDED, RunStatus.FAILED, RunStatus.CANCELLED}:
|
||||||
|
run.finished_at = now
|
||||||
|
if run.started_at:
|
||||||
|
run.duration_ms = max(0, int((now - run.started_at).total_seconds() * 1000))
|
||||||
|
if target_status == RunStatus.SUCCEEDED:
|
||||||
|
run.output_summary = sanitize_summary(output or {})
|
||||||
|
if target_status == RunStatus.FAILED:
|
||||||
|
run.error_code = error_code[:80]
|
||||||
|
run.error_summary = error_summary[:2000]
|
||||||
|
run.save()
|
||||||
|
_append_event(run, f"run_{target_status}", f"运行状态变更为 {run.get_status_display()}")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def request_approval(run_id, request_key: str, approval_type: str, summary=None):
|
||||||
|
"""暂停运行并创建唯一的待确认请求。"""
|
||||||
|
|
||||||
|
run = AgentRun.objects.select_for_update().get(pk=run_id)
|
||||||
|
if run.status != RunStatus.RUNNING:
|
||||||
|
raise InvalidStateTransition("只有运行中的任务可以请求人工确认。")
|
||||||
|
approval = HumanApproval.objects.create(
|
||||||
|
run=run,
|
||||||
|
request_key=request_key,
|
||||||
|
approval_type=approval_type,
|
||||||
|
request_summary=sanitize_summary(summary or {}),
|
||||||
|
requested_at=timezone.now(),
|
||||||
|
)
|
||||||
|
run.status = RunStatus.WAITING_APPROVAL
|
||||||
|
run.lock_version += 1
|
||||||
|
run.save(update_fields=("status", "lock_version", "updated_at"))
|
||||||
|
_append_event(run, "approval_requested", "运行等待人工确认", {"request_key": request_key})
|
||||||
|
return approval
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def start_tool_call(run_id, call_id: str, tool_name: str, arguments=None, idempotency_key=""):
|
||||||
|
"""在外部调用前登记开始状态;唯一约束负责阻止重复调用标识和幂等键。"""
|
||||||
|
|
||||||
|
run = AgentRun.objects.select_for_update().get(pk=run_id)
|
||||||
|
if run.status != RunStatus.RUNNING:
|
||||||
|
raise InvalidStateTransition("只有运行中的任务可以开始工具调用。")
|
||||||
|
call = ToolCall.objects.create(
|
||||||
|
run=run,
|
||||||
|
call_id=call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
arguments_summary=sanitize_summary(arguments or {}),
|
||||||
|
started_at=timezone.now(),
|
||||||
|
)
|
||||||
|
_append_event(run, "tool_started", f"工具 {tool_name} 开始执行", {"call_id": call_id})
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def finish_tool_call(call_id: int, *, result=None, error_code="", error_summary=""):
|
||||||
|
"""在外部调用结束后的独立短事务中登记成功或失败结果。"""
|
||||||
|
|
||||||
|
call = ToolCall.objects.select_for_update().select_related("run").get(pk=call_id)
|
||||||
|
if call.status != ToolCallStatus.STARTED:
|
||||||
|
raise InvalidStateTransition("工具调用已经结束。")
|
||||||
|
now = timezone.now()
|
||||||
|
call.finished_at = now
|
||||||
|
call.duration_ms = max(0, int((now - call.started_at).total_seconds() * 1000))
|
||||||
|
if error_code:
|
||||||
|
call.status = ToolCallStatus.FAILED
|
||||||
|
call.error_code = error_code[:80]
|
||||||
|
call.error_summary = error_summary[:2000]
|
||||||
|
event_type, summary = "tool_failed", f"工具 {call.tool_name} 执行失败"
|
||||||
|
else:
|
||||||
|
call.status = ToolCallStatus.SUCCEEDED
|
||||||
|
call.result_summary = sanitize_summary(result or {})
|
||||||
|
event_type, summary = "tool_completed", f"工具 {call.tool_name} 执行完成"
|
||||||
|
call.save()
|
||||||
|
run = AgentRun.objects.select_for_update().get(pk=call.run_id)
|
||||||
|
_append_event(run, event_type, summary, {"call_id": call.call_id})
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def resolve_approval(actor, approval_id, approved: bool, summary=None):
|
||||||
|
"""只允许所属用户或管理员处理一次待确认请求。"""
|
||||||
|
|
||||||
|
approval = HumanApproval.objects.select_for_update().select_related("run").get(pk=approval_id)
|
||||||
|
if actor != approval.run.owner and not actor.is_staff:
|
||||||
|
raise PermissionDenied("无权处理该确认请求。")
|
||||||
|
if approval.status != ApprovalStatus.PENDING:
|
||||||
|
raise InvalidStateTransition("该确认请求已经处理。")
|
||||||
|
approval.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||||
|
approval.resolved_by = actor
|
||||||
|
approval.resolved_at = timezone.now()
|
||||||
|
approval.decision_summary = sanitize_summary(summary or {})
|
||||||
|
approval.save()
|
||||||
|
run = AgentRun.objects.select_for_update().get(pk=approval.run_id)
|
||||||
|
run.status = RunStatus.RUNNING if approved else RunStatus.CANCELLED
|
||||||
|
run.lock_version += 1
|
||||||
|
if not approved:
|
||||||
|
run.finished_at = timezone.now()
|
||||||
|
run.save()
|
||||||
|
_append_event(run, "approval_resolved", "人工确认已处理", {"approved": approved})
|
||||||
|
return approval
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from common.exceptions import InvalidStateTransition
|
||||||
|
|
||||||
|
from .models import RunStatus
|
||||||
|
from .services import create_run, finish_tool_call, start_tool_call, transition_run
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunTests(TestCase):
|
||||||
|
"""覆盖运行状态、事件顺序、脱敏和跨用户拒绝路径。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
users = get_user_model().objects
|
||||||
|
self.alice = users.create_user(username="alice", password="safe-pass-123")
|
||||||
|
self.bob = users.create_user(username="bob", password="safe-pass-123")
|
||||||
|
|
||||||
|
def test_run_lifecycle_and_sensitive_summary(self):
|
||||||
|
run = create_run(self.alice, "基线运行", {"api_key": "secret", "query": "Python"})
|
||||||
|
self.assertEqual(run.input_summary["api_key"], "***")
|
||||||
|
transition_run(run.id, RunStatus.RUNNING)
|
||||||
|
finished = transition_run(run.id, RunStatus.SUCCEEDED, output={"count": 1})
|
||||||
|
self.assertEqual(finished.status, RunStatus.SUCCEEDED)
|
||||||
|
self.assertEqual(list(finished.events.values_list("sequence", flat=True)), [1, 2, 3])
|
||||||
|
with self.assertRaises(InvalidStateTransition):
|
||||||
|
transition_run(run.id, RunStatus.RUNNING)
|
||||||
|
|
||||||
|
def test_cross_user_detail_returns_404(self):
|
||||||
|
run = create_run(self.alice, "私有运行")
|
||||||
|
self.client.force_login(self.bob)
|
||||||
|
response = self.client.get(reverse("agent_runtime:run-detail", args=(run.id,)))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_tool_call_is_recorded_and_sanitized(self):
|
||||||
|
run = create_run(self.alice, "工具运行")
|
||||||
|
transition_run(run.id, RunStatus.RUNNING)
|
||||||
|
call = start_tool_call(run.id, "call-1", "demo_tool", {"password": "secret"})
|
||||||
|
self.assertEqual(call.arguments_summary["password"], "***")
|
||||||
|
finished = finish_tool_call(call.id, result={"count": 1})
|
||||||
|
self.assertEqual(finished.status, "succeeded")
|
||||||
|
self.assertEqual(finished.result_summary, {"count": 1})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "agent_runtime"
|
||||||
|
urlpatterns = [path("", views.run_list, name="run-list"), path("<uuid:run_id>/", views.run_detail, name="run-detail")]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""当前用户的 Agent Run 查询页面。"""
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
|
||||||
|
from .services import runs_for_user
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def run_list(request):
|
||||||
|
"""分页显示当前用户的运行记录。"""
|
||||||
|
|
||||||
|
page = Paginator(runs_for_user(request.user), 20).get_page(request.GET.get("page"))
|
||||||
|
return render(request, "agent_runtime/run_list.html", {"page": page})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def run_detail(request, run_id):
|
||||||
|
"""越权与不存在统一返回 404,避免泄露资源存在性。"""
|
||||||
|
|
||||||
|
run = get_object_or_404(
|
||||||
|
runs_for_user(request.user).prefetch_related("events", "tool_calls", "approvals"), pk=run_id
|
||||||
|
)
|
||||||
|
return render(request, "agent_runtime/run_detail.html", {"run": run})
|
||||||
Reference in New Issue
Block a user