From befd6826750219051a3cdd381850e3484eafc789 Mon Sep 17 00:00:00 2001 From: bruce Date: Wed, 9 Sep 2026 11:58:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent-runtime):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AE=A1=E8=AE=A1=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_runtime/__init__.py | 1 + agent_runtime/admin.py | 53 +++++++ agent_runtime/apps.py | 8 ++ agent_runtime/gateway.py | 39 ++++++ agent_runtime/migrations/0001_initial.py | 135 ++++++++++++++++++ agent_runtime/migrations/__init__.py | 0 agent_runtime/models.py | 135 ++++++++++++++++++ agent_runtime/services.py | 170 +++++++++++++++++++++++ agent_runtime/tests.py | 42 ++++++ agent_runtime/urls.py | 6 + agent_runtime/views.py | 25 ++++ 11 files changed, 614 insertions(+) create mode 100644 agent_runtime/__init__.py create mode 100644 agent_runtime/admin.py create mode 100644 agent_runtime/apps.py create mode 100644 agent_runtime/gateway.py create mode 100644 agent_runtime/migrations/0001_initial.py create mode 100644 agent_runtime/migrations/__init__.py create mode 100644 agent_runtime/models.py create mode 100644 agent_runtime/services.py create mode 100644 agent_runtime/tests.py create mode 100644 agent_runtime/urls.py create mode 100644 agent_runtime/views.py diff --git a/agent_runtime/__init__.py b/agent_runtime/__init__.py new file mode 100644 index 0000000..a0ae870 --- /dev/null +++ b/agent_runtime/__init__.py @@ -0,0 +1 @@ +"""Agent 运行记录与生命周期应用。""" diff --git a/agent_runtime/admin.py b/agent_runtime/admin.py new file mode 100644 index 0000000..0070366 --- /dev/null +++ b/agent_runtime/admin.py @@ -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 diff --git a/agent_runtime/apps.py b/agent_runtime/apps.py new file mode 100644 index 0000000..ff6bd11 --- /dev/null +++ b/agent_runtime/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class AgentRuntimeConfig(AppConfig): + """Agent 运行持久化应用配置。""" + + default_auto_field = "django.db.models.BigAutoField" + name = "agent_runtime" diff --git a/agent_runtime/gateway.py b/agent_runtime/gateway.py new file mode 100644 index 0000000..5e2b4d1 --- /dev/null +++ b/agent_runtime/gateway.py @@ -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 diff --git a/agent_runtime/migrations/0001_initial.py b/agent_runtime/migrations/0001_initial.py new file mode 100644 index 0000000..7fd83dc --- /dev/null +++ b/agent_runtime/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/agent_runtime/migrations/__init__.py b/agent_runtime/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent_runtime/models.py b/agent_runtime/models.py new file mode 100644 index 0000000..ddb6de1 --- /dev/null +++ b/agent_runtime/models.py @@ -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")] diff --git a/agent_runtime/services.py b/agent_runtime/services.py new file mode 100644 index 0000000..39f9595 --- /dev/null +++ b/agent_runtime/services.py @@ -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 diff --git a/agent_runtime/tests.py b/agent_runtime/tests.py new file mode 100644 index 0000000..3e248b8 --- /dev/null +++ b/agent_runtime/tests.py @@ -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}) diff --git a/agent_runtime/urls.py b/agent_runtime/urls.py new file mode 100644 index 0000000..1dbb4d0 --- /dev/null +++ b/agent_runtime/urls.py @@ -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("/", views.run_detail, name="run-detail")] diff --git a/agent_runtime/views.py b/agent_runtime/views.py new file mode 100644 index 0000000..374f94f --- /dev/null +++ b/agent_runtime/views.py @@ -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})