Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 309 additions & 2 deletions backend/api/v1/chat.py

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions backend/migrations/versions/a8b9c0d1e2f3_add_durable_agent_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""add durable Agent sessions, runs, and public events

Revision ID: a8b9c0d1e2f3
Revises: k8l9m0n1o2p3
Create Date: 2026-08-06
"""

import sqlalchemy as sa
from alembic import op

revision = "a8b9c0d1e2f3"
down_revision = "k8l9m0n1o2p3"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"agent_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("workspace_id", sa.String(36), nullable=True),
sa.Column("actor_subject", sa.String(255), nullable=True),
sa.Column("context", sa.JSON(), nullable=False),
)
op.create_index("ix_agent_sessions_workspace_id", "agent_sessions", ["workspace_id"])
op.create_index("ix_agent_sessions_actor_subject", "agent_sessions", ["actor_subject"])
op.create_table(
"agent_runs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"session_id",
sa.String(36),
sa.ForeignKey("agent_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("goal", sa.Text(), nullable=False),
sa.Column("request_payload", sa.JSON(), nullable=False),
sa.Column("reply_payload", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("next_event_sequence", sa.Integer(), nullable=False),
)
op.create_index("ix_agent_runs_session_id", "agent_runs", ["session_id"])
op.create_index("ix_agent_runs_status", "agent_runs", ["status"])
op.create_table(
"agent_run_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"run_id",
sa.String(36),
sa.ForeignKey("agent_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("sequence", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(64), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.UniqueConstraint("run_id", "sequence", name="ux_agent_run_events_run_id_sequence"),
)
op.create_index("ix_agent_run_events_run_id", "agent_run_events", ["run_id"])


def downgrade() -> None:
op.drop_table("agent_run_events")
op.drop_table("agent_runs")
op.drop_table("agent_sessions")
4 changes: 4 additions & 0 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
from backend.models.worker import WorkerNode
from backend.models.workflow import Project, Workflow, WorkflowDraft, WorkflowVersion
from backend.models.workflow_run import WorkflowRun, WorkflowRunEvent
from backend.models.agent_run import AgentRun, AgentRunEvent, AgentSession

__all__ = [
"TimestampMixin",
Expand Down Expand Up @@ -152,4 +153,7 @@
"WorkflowVersion",
"WorkflowRun",
"WorkflowRunEvent",
"AgentSession",
"AgentRun",
"AgentRunEvent",
]
57 changes: 57 additions & 0 deletions backend/models/agent_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Durable public execution records for interactive Agent runs."""

from sqlalchemy import JSON, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship

from backend.models.base import TimestampMixin


class AgentSession(TimestampMixin):
__tablename__ = "agent_sessions"

workspace_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
actor_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
context: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

runs: Mapped[list["AgentRun"]] = relationship(
"AgentRun", back_populates="session", cascade="all, delete-orphan"
)


class AgentRun(TimestampMixin):
__tablename__ = "agent_runs"

session_id: Mapped[str] = mapped_column(
String(36), ForeignKey("agent_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String(32), nullable=False, default="chat")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
goal: Mapped[str] = mapped_column(Text, nullable=False, default="")
request_payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
reply_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
next_event_sequence: Mapped[int] = mapped_column(Integer, nullable=False, default=1)

session: Mapped[AgentSession] = relationship("AgentSession", back_populates="runs")
events: Mapped[list["AgentRunEvent"]] = relationship(
"AgentRunEvent",
back_populates="run",
cascade="all, delete-orphan",
order_by="AgentRunEvent.sequence",
)


class AgentRunEvent(TimestampMixin):
__tablename__ = "agent_run_events"
__table_args__ = (
Index("ux_agent_run_events_run_id_sequence", "run_id", "sequence", unique=True),
)

run_id: Mapped[str] = mapped_column(
String(36), ForeignKey("agent_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)

run: Mapped[AgentRun] = relationship("AgentRun", back_populates="events")
37 changes: 36 additions & 1 deletion docs/backend-capability-exposure-matrix.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: 1
source: backend.main.app.openapi
openapi_operation_count: 231
openapi_operation_count: 234
allowed_dispositions:
- operator_ui
- studio_binding
Expand Down Expand Up @@ -237,6 +237,14 @@ capability_groups:
owner: opencli-admin
wrapper_names: []
workflow_node_ids: []
- capability_id: agent.runs
label: Agent 运行与可观测
projection: operator_resource
distribution: builtin
lifecycle: active
owner: opencli-admin
wrapper_names: []
workflow_node_ids: []
operations:
- method: GET
path: /api/v1/agents
Expand Down Expand Up @@ -2317,6 +2325,33 @@ operations:
decision: Start only published project workflows through the Studio runtime boundary.
target_epic: Epic 8
capability_id: studio.workflow
- method: GET
path: /api/v1/chat/runs/{run_id}
operation_id: get_chat_run_api_v1_chat_runs__run_id__get
disposition: studio_binding
frontend_route: /operations-agents
wrapper: null
decision: Agent run detail for the Operations/Agents page (durable agent runs, ex-PR #61 T2).
target_epic: Epic 8
capability_id: agent.runs
- method: GET
path: /api/v1/chat/runs/{run_id}/events
operation_id: get_chat_run_events_api_v1_chat_runs__run_id__events_get
disposition: studio_binding
frontend_route: /operations-agents
wrapper: null
decision: Agent run event stream consumed by the global agent dock live view.
target_epic: Epic 8
capability_id: agent.runs
- method: POST
path: /api/v1/chat/stream
operation_id: chat_stream_api_v1_chat_stream_post
disposition: studio_binding
frontend_route: /operations-agents
wrapper: null
decision: Streaming chat endpoint for observable agent execution.
target_epic: Epic 8
capability_id: agent.runs
unreferenced_wrappers:
- wrapper: getOperationsAgentVersion
operation_id: get_agent_version_api_v1_workspaces__workspace_id__operations_agents__agent_id__versions__version_number__get
Expand Down
11 changes: 10 additions & 1 deletion frontend/app/(app)/operations-agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ function parseJsonObject(value: string, label: string) {
return parsed as Record<string, unknown>
}

function publicRunSummary(payload: Record<string, unknown> | null) {
if (!payload) return null
const values = Object.entries(payload)
.filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
.slice(0, 4)
.map(([key, value]) => `${key}: ${String(value)}`)
return values.length ? values.join(' · ') : '已生成结构化执行结果,可在运行记录中审计。'
}

function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: OperationsAgent }) {
const draft = useOperationsAgentDraft(workspaceId, agent.id)
const versions = useOperationsAgentVersions(workspaceId, agent.id)
Expand Down Expand Up @@ -346,7 +355,7 @@ export default function OperationsAgentsPage() {
<div className="flex min-h-0 flex-col bg-[#090a0b]">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-2 font-mono text-[11px] text-muted-foreground"><Terminal className="size-3" />SESSION OUTPUT</div>
<div className="min-h-0 flex-1 overflow-y-auto p-5 font-mono text-xs leading-6">
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ? <div className="space-y-5">{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) => <div key={run.id}><div className="text-muted-foreground"><span className="text-emerald-400">[{run.status}]</span> {new Date(run.updated_at).toLocaleString()}</div><div className="mt-1 text-white/80">{run.trigger_type} → {run.target_resource_type}/{run.target_resource_id}</div><div className="text-white/45">profile v{run.profile_version} · agent v{run.published_version}</div>{run.error_message ? <div className="mt-2 whitespace-pre-wrap text-red-300">{run.error_message}</div> : null}{run.output_payload ? <pre className="mt-2 overflow-x-auto whitespace-pre-wrap text-white/75">{JSON.stringify(run.output_payload, null, 2)}</pre> : null}</div>)}</div> : <div className="flex h-full min-h-56 flex-col items-center justify-center text-center font-sans"><Terminal className="mb-3 size-6 text-white/25" /><p className="text-sm text-white/65">还没有会话输出</p><p className="mt-1 max-w-xs text-xs leading-5 text-white/35">智能体收到任务后,这里会显示真实的 CLI 活动和运行状态。</p></div>}
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ? <div className="space-y-4">{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) => <article key={run.id} className="rounded-lg border border-white/[0.08] bg-white/[0.025] p-4"><div className="flex items-center justify-between gap-3"><span className={cn('font-medium', run.status === 'failed' ? 'text-red-300' : run.status === 'completed' ? 'text-emerald-400' : 'text-sky-300')}>{run.status === 'queued' ? '等待执行' : run.status === 'running' ? '正在执行' : run.status === 'completed' ? '已完成' : run.status === 'paused' ? '等待确认' : run.status === 'cancelled' ? '已取消' : '执行失败'}</span><time className="text-muted-foreground">{new Date(run.updated_at).toLocaleString()}</time></div><p className="mt-2 text-sm text-white/85">目标:{run.target_resource_type} · {run.target_resource_id}</p>{run.error_message ? <div className="mt-3 rounded-md bg-red-400/10 px-3 py-2 text-red-200">{run.error_message}<p className="mt-1 text-red-200/70">检查目标状态后可以重新启动。</p></div> : null}{publicRunSummary(run.output_payload) ? <div className="mt-3 rounded-md bg-emerald-400/10 px-3 py-2 text-emerald-100">结果:{publicRunSummary(run.output_payload)}</div> : null}</article>)}</div> : <div className="flex h-full min-h-56 flex-col items-center justify-center text-center font-sans"><Terminal className="mb-3 size-6 text-white/25" /><p className="text-sm text-white/65">还没有执行活动</p><p className="mt-1 max-w-xs text-xs leading-5 text-white/35">智能体收到任务后,这里会显示目标、当前状态和结果摘要。</p></div>}
</div>
<div className="border-t border-white/[0.06] p-3">
<details className="rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2">
Expand Down
Loading
Loading